From da52aa5c3cf4d62bfc416262671a03f224d5175f Mon Sep 17 00:00:00 2001 From: BruceChen Date: Sun, 19 Apr 2026 17:03:03 +0000 Subject: [PATCH] pathing: sidewall runup precondition via EntryPreparation Introduce an EntryPreparationState carried on PathNode + A* context so sidewall parkour can explicitly request one or more runway traverses before takeoff instead of silently dropping the move. ParkourFeasibility gains TryGetRequiredStaticEntryRunupSteps + HasPreparedRunup helpers so long descends (major=5, dy=-1) only remain feasible when the preceding node proved the runup. Widen HasDominantAxisRunUp to accept cold-start sprint-jumps within ~3.1-3.5 blocks horizontally so lone overhang / staircase takeoffs stay feasible without a 2-block runway (matches Baritone's MomentumBehavior .ALLOWED contract). Add a runtime SidewallParkourController that implements the corner commitment + wall-hug chain during execution. Extend pathing test fixtures with InitialMomentumTicks, add sidewall accepted/rejected scenarios, and refresh timing + contract baselines to reflect the new planner shapes. Document the design in docs/superpowers/specs and plans. Made-with: Cursor --- .../Execution/LivePathingRegressionTests.cs | 45 + .../Execution/PathSegmentManagerTests.cs | 16 + .../Scenarios/PathingExecutionScenario.cs | 1 + .../SprintJumpTemplateScenarioTests.cs | 273 ++++- .../Pathing/pathing-planner-contracts.json | 15 +- .../Pathing/pathing-timing-budgets.json | 15 +- .../Pathing/Core/CalculationContext.cs | 1 + .../Pathing/Core/EntryPreparationKind.cs | 8 + .../Pathing/Core/EntryPreparationState.cs | 29 + MinecraftClient/Pathing/Core/PathNode.cs | 1 + .../Templates/SidewallParkourController.cs | 684 +++++++++++ .../Pathing/Moves/ParkourFeasibility.cs | 56 + ...2026-04-18-sidewall-parkour-zero-replan.md | 1035 +++++++++++++++++ ...-04-19-sidewall-runup-precondition-plan.md | 596 ++++++++++ ...4-19-sidewall-runup-precondition-design.md | 175 +++ 15 files changed, 2925 insertions(+), 25 deletions(-) create mode 100644 MinecraftClient/Pathing/Core/EntryPreparationKind.cs create mode 100644 MinecraftClient/Pathing/Core/EntryPreparationState.cs create mode 100644 MinecraftClient/Pathing/Execution/Templates/SidewallParkourController.cs create mode 100644 docs/superpowers/plans/2026-04-18-sidewall-parkour-zero-replan.md create mode 100644 docs/superpowers/plans/2026-04-19-sidewall-runup-precondition-plan.md create mode 100644 docs/superpowers/specs/2026-04-19-sidewall-runup-precondition-design.md diff --git a/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs b/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs index a93f30eb..5e11fe4e 100644 --- a/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs @@ -146,6 +146,27 @@ public sealed class LivePathingRegressionTests segment => Assert.Equal(ParkourProfile.Default, segment.ParkourProfile)); } + [Fact] + public void AStar_LinearFlatGap4_DoesNotInsertRunupSetupSegments() + { + 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); + int firstParkourIndex = segments.FindIndex(segment => segment.MoveType == MoveType.Parkour); + + Assert.Equal(3, firstParkourIndex); + Assert.All( + segments.Take(firstParkourIndex), + segment => + { + Assert.Equal(MoveType.Traverse, segment.MoveType); + Assert.True(segment.End.X > segment.Start.X, segment.ToString()); + }); + Assert.Equal(new Location(3.5, 80, 0.5), segments[firstParkourIndex - 1].End); + } + [Theory] [InlineData("linear-ascend-gap2-dy+1", 2, 1)] [InlineData("linear-descend-gap4-dy-1", 4, -1)] @@ -225,6 +246,30 @@ public sealed class LivePathingRegressionTests Assert.Equal(scenario.Goal.Z + 0.5, segments[^1].End.Z); } + [Theory] + [InlineData("sidewall-descend-gap5-dy-1-wo0", 5, 0)] + [InlineData("sidewall-descend-gap5-dy-1-wo1", 5, 1)] + public void AStar_SidewallLongDescendStaticEntry_PrependsExplicitRunupTraverses(string scenarioId, int gap, int wallOffset) + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create( + scenarioId, + gap, + deltaY: -1, + wallOffset); + PathResult result = PathingScenarioRunner.PlanOnly(scenario); + List segments = PathSegmentBuilder.FromPath(result.Path); + + Assert.Equal(PathStatus.Success, result.Status); + + int firstParkourIndex = segments.FindIndex(segment => segment.MoveType == MoveType.Parkour); + Assert.True(firstParkourIndex >= 2, string.Join('\n', segments)); + Assert.All( + segments.Take(firstParkourIndex), + segment => Assert.Equal(MoveType.Traverse, segment.MoveType)); + Assert.Equal(new Location(100.5, 80, 100.5), segments[firstParkourIndex - 1].End); + Assert.Equal(ParkourProfile.Sidewall, segments[firstParkourIndex].ParkourProfile); + } + [Theory] [MemberData(nameof(SidewallParkourScenarioBuilder.RejectedCases), MemberType = typeof(SidewallParkourScenarioBuilder))] public void AStar_SidewallRejectedCases_RejectBeforeExecution(string scenarioId, int gap, int deltaY, int wallOffset) diff --git a/MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs index 04dbc9ea..8b4e31cd 100644 --- a/MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs @@ -225,6 +225,22 @@ public sealed class PathSegmentManagerTests $"replanCount={manager.ReplanCount}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}"); } + [Theory] + [MemberData(nameof(SidewallParkourScenarioBuilder.AcceptedCases), MemberType = typeof(SidewallParkourScenarioBuilder))] + public void Tick_SidewallAcceptedCases_CompletesWithoutReplan(string scenarioId, int gap, int deltaY, int wallOffset) + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create(scenarioId, gap, deltaY, wallOffset); + 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)}"); + } + [Theory] [MemberData(nameof(LinearParkourScenarioBuilder.AcceptedCases), MemberType = typeof(LinearParkourScenarioBuilder))] public void Tick_LinearAcceptedChain_CompletesWithoutReplan(string scenarioId, int gap, int deltaY) diff --git a/MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenario.cs b/MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenario.cs index a561c02c..e404a3f6 100644 --- a/MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenario.cs +++ b/MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenario.cs @@ -10,5 +10,6 @@ internal sealed record PathingExecutionScenario public required Location Start { get; init; } public required GoalBlock Goal { get; init; } public required float StartYaw { get; init; } + public int InitialMomentumTicks { get; init; } public required int MaxExecutionTicks { get; init; } } diff --git a/MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs b/MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs index 7976a6b1..2a705396 100644 --- a/MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs @@ -640,7 +640,278 @@ public sealed class SprintJumpTemplateScenarioTests Assert.True(input.Jump); } + [Fact] + public void SprintJumpTemplate_SidewallFlatGap2_FinalStop_CompletesInsideLandingBlock() + { + World world = SidewallParkourScenarioBuilder.BuildWorld(gap: 2, deltaY: 0, wallOffset: 0); + var segment = new PathSegment + { + Start = new Location(100.5, 80, 100.5), + End = new Location(99.5, 80, 102.5), + MoveType = MoveType.Parkour, + ParkourProfile = ParkourProfile.Sidewall, + ExitTransition = PathTransitionType.FinalStop + }; + + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 0f); + PathSegment[] segments = [segment]; + + TemplateState state = RunSegment(segments, index: 0, physics, world, out Location finalPos, out string trace); + + Assert.True( + state == TemplateState.Complete, + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segment}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segment}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallFlatGap2_SecondPrepareJump_CompletesFromChainCarry() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-flat-gap2-wo0", gap: 2, deltaY: 0, wallOffset: 0); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + RunSegmentsThrough(segments, world, physics, lastCompletedIndex: 0); + + TemplateState state = RunSegment(segments, index: 1, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[1]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[1].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[1]}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallFlatGap3Wo1_FirstPrepareJump_CompletesFromStart() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-flat-gap3-wo1", gap: 3, deltaY: 0, wallOffset: 1); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + TemplateState state = RunSegment(segments, index: 0, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[0].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallFlatGap4Wo0_FirstPrepareJump_CompletesFromStart() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-flat-gap4-wo0", gap: 4, deltaY: 0, wallOffset: 0); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + TemplateState state = RunSegment(segments, index: 0, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[0].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallFlatGap3Wo1_SecondPrepareJump_CompletesFromChainCarry() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-flat-gap3-wo1", gap: 3, deltaY: 0, wallOffset: 1); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + RunSegmentsThrough(segments, world, physics, lastCompletedIndex: 0); + + TemplateState state = RunSegment(segments, index: 1, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[1]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[1].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[1]}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallAscendGap3Wo1_FirstPrepareJump_CompletesFromStart() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-ascend-gap3-dy+1-wo1", gap: 3, deltaY: 1, wallOffset: 1); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + TemplateState state = RunSegment(segments, index: 0, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[0].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallDescendGap2DyMinus1Wo0_FirstPrepareJump_CompletesFromStart() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-descend-gap2-dy-1-wo0", gap: 2, deltaY: -1, wallOffset: 0); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + TemplateState state = RunSegment(segments, index: 0, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[0].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallDescendGap2DyMinus2Wo0_FirstPrepareJump_CompletesFromStart() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-descend-gap2-dy-2-wo0", gap: 2, deltaY: -2, wallOffset: 0); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + TemplateState state = RunSegment(segments, index: 0, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[0].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallDescendGap2DyMinus1Wo0_FinalStop_CompletesFromChainCarry() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-descend-gap2-dy-1-wo0", gap: 2, deltaY: -1, wallOffset: 0); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + RunSegmentsThrough(segments, world, physics, lastCompletedIndex: 1); + + TemplateState state = RunSegment(segments, index: 2, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[2]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[2].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[2]}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallDescendGap2DyMinus2Wo0_FinalStop_CompletesFromChainCarry() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-descend-gap2-dy-2-wo0", gap: 2, deltaY: -2, wallOffset: 0); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + RunSegmentsThrough(segments, world, physics, lastCompletedIndex: 1); + + TemplateState state = RunSegment(segments, index: 2, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[2]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[2].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[2]}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallDescendGap5DyMinus1Wo0_FirstPrepareJump_CompletesFromStart() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-descend-gap5-dy-1-wo0", gap: 5, deltaY: -1, wallOffset: 0); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + TemplateState state = RunSegment(segments, index: 0, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[0].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallDescendGap5DyMinus1Wo1_FirstPrepareJump_CompletesFromStart() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-descend-gap5-dy-1-wo1", gap: 5, deltaY: -1, wallOffset: 1); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + TemplateState state = RunSegment(segments, index: 0, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[0].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallDescendGap5DyMinus2Wo0_FirstPrepareJump_CompletesFromStart() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-descend-gap5-dy-2-wo0", gap: 5, deltaY: -2, wallOffset: 0); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + TemplateState state = RunSegment(segments, index: 0, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[0].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[0]}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallDescendGap5DyMinus2Wo0_FinalStop_CompletesFromChainCarry() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-descend-gap5-dy-2-wo0", gap: 5, deltaY: -2, wallOffset: 0); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + RunSegmentsThrough(segments, world, physics, lastCompletedIndex: 1); + + TemplateState state = RunSegment(segments, index: 2, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[2]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[2].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[2]}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallDescendGap3DyMinus1Wo0_FinalStop_CompletesFromChainCarry() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-descend-gap3-dy-1-wo0", gap: 3, deltaY: -1, wallOffset: 0); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + RunSegmentsThrough(segments, world, physics, lastCompletedIndex: 1); + + TemplateState state = RunSegment(segments, index: 2, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[2]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[2].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[2]}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallFlatGap2_FinalStop_CompletesFromChainCarry() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-flat-gap2-wo0", gap: 2, deltaY: 0, wallOffset: 0); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + RunSegmentsThrough(segments, world, physics, lastCompletedIndex: 1); + + TemplateState state = RunSegment(segments, index: 2, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[2]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[2].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[2]}\n{trace}"); + } + + [Fact] + public void SprintJumpTemplate_SidewallDescendGap4DyMinus1Wo0_FinalStop_CompletesInsideLandingBlock() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create("sidewall-descend-gap4-dy-1-wo0", gap: 4, deltaY: -1, wallOffset: 0); + (World world, List segments, PlayerPhysics physics) = BuildPlannedScenario(scenario); + + RunSegmentsThrough(segments, world, physics, lastCompletedIndex: 1); + + TemplateState state = RunSegment(segments, index: 2, physics, world, out Location finalPos, out string trace); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[2]}\n{trace}"); + Assert.True( + TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segments[2].End), + $"state={state} finalPos={finalPos} vel={physics.DeltaMovement} segment={segments[2]}\n{trace}"); + } + private static (World World, List Segments, PlayerPhysics Physics) BuildPlannedLinearScenario(PathingExecutionScenario scenario) + { + return BuildPlannedScenario(scenario); + } + + private static (World World, List Segments, PlayerPhysics Physics) BuildPlannedScenario(PathingExecutionScenario scenario) { PathResult planResult = PathingScenarioRunner.PlanOnly(scenario); @@ -685,7 +956,7 @@ public sealed class SprintJumpTemplateScenarioTests state = template.Tick(pos, physics, input, world); tail.Enqueue( $"tick={tick} state={state} 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})"); + $"input(F={input.Forward},B={input.Back},L={input.Left},R={input.Right},J={input.Jump},S={input.Sprint})"); if (tail.Count > 40) tail.Dequeue(); diff --git a/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json b/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json index 4be87817..2d59afbf 100644 --- a/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json +++ b/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json @@ -321,26 +321,13 @@ "endBlock": { "x": 622, "y": 80, - "z": 620 - } - }, - { - "moveType": "Parkour", - "startBlock": { - "x": 622, - "y": 80, - "z": 620 - }, - "endBlock": { - "x": 624, - "y": 80, "z": 621 } }, { "moveType": "Parkour", "startBlock": { - "x": 624, + "x": 622, "y": 80, "z": 621 }, diff --git a/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json b/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json index 1d64eeda..dc0978a4 100644 --- a/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json +++ b/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json @@ -148,22 +148,17 @@ { "scenarioId": "obstructed-parkour-l-turns", "expectedTotalTicks": 50, - "maxTotalTicks": 62, + "maxTotalTicks": 80, "segments": [ { "moveType": "Parkour", - "expectedTicks": 13, - "maxTicks": 16 + "expectedTicks": 20, + "maxTicks": 32 }, { "moveType": "Parkour", - "expectedTicks": 21, - "maxTicks": 26 - }, - { - "moveType": "Parkour", - "expectedTicks": 16, - "maxTicks": 20 + "expectedTicks": 30, + "maxTicks": 48 } ] }, diff --git a/MinecraftClient/Pathing/Core/CalculationContext.cs b/MinecraftClient/Pathing/Core/CalculationContext.cs index fe732334..181f83c3 100644 --- a/MinecraftClient/Pathing/Core/CalculationContext.cs +++ b/MinecraftClient/Pathing/Core/CalculationContext.cs @@ -22,6 +22,7 @@ namespace MinecraftClient.Pathing.Core public double SprintCost { get; } public double SneakCost { get; } public MoveType PreviousMoveType { get; internal set; } + public EntryPreparationState CurrentEntryPreparation { get; internal set; } public CalculationContext( World world, diff --git a/MinecraftClient/Pathing/Core/EntryPreparationKind.cs b/MinecraftClient/Pathing/Core/EntryPreparationKind.cs new file mode 100644 index 00000000..4ea73979 --- /dev/null +++ b/MinecraftClient/Pathing/Core/EntryPreparationKind.cs @@ -0,0 +1,8 @@ +namespace MinecraftClient.Pathing.Core +{ + public enum EntryPreparationKind + { + None = 0, + SidewallRunup = 1 + } +} diff --git a/MinecraftClient/Pathing/Core/EntryPreparationState.cs b/MinecraftClient/Pathing/Core/EntryPreparationState.cs new file mode 100644 index 00000000..74ba33a5 --- /dev/null +++ b/MinecraftClient/Pathing/Core/EntryPreparationState.cs @@ -0,0 +1,29 @@ +namespace MinecraftClient.Pathing.Core +{ + public readonly record struct EntryPreparationState( + EntryPreparationKind Kind, + int OriginX, + int OriginY, + int OriginZ, + int ForwardX, + int ForwardZ, + byte RequiredSteps, + byte BackwardSteps, + byte ReturnSteps) + { + public static EntryPreparationState None => default; + + public bool IsNone => Kind == EntryPreparationKind.None; + + public bool IsPrepared => + Kind != EntryPreparationKind.None && + BackwardSteps == RequiredSteps && + ReturnSteps == RequiredSteps; + + public EntryPreparationState AdvanceBackward() => + this with { BackwardSteps = (byte)(BackwardSteps + 1) }; + + public EntryPreparationState AdvanceReturn() => + this with { ReturnSteps = (byte)(ReturnSteps + 1) }; + } +} diff --git a/MinecraftClient/Pathing/Core/PathNode.cs b/MinecraftClient/Pathing/Core/PathNode.cs index 85ced9a6..5efd80fc 100644 --- a/MinecraftClient/Pathing/Core/PathNode.cs +++ b/MinecraftClient/Pathing/Core/PathNode.cs @@ -16,6 +16,7 @@ namespace MinecraftClient.Pathing.Core public PathNode? Parent; public MoveType MoveUsed; public ParkourProfile ParkourProfile; + public EntryPreparationState EntryPreparation; public int HeapIndex; public bool IsOpen; diff --git a/MinecraftClient/Pathing/Execution/Templates/SidewallParkourController.cs b/MinecraftClient/Pathing/Execution/Templates/SidewallParkourController.cs new file mode 100644 index 00000000..0c426715 --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Templates/SidewallParkourController.cs @@ -0,0 +1,684 @@ +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Physics; + +namespace MinecraftClient.Pathing.Execution.Templates +{ + internal sealed class SidewallParkourController + { + private enum PrepareJumpAirProfile + { + Hold, + Coast, + Release + } + + private enum Phase + { + Approach, + Airborne, + Landing + } + + internal Location ExpectedStart { get; } + internal Location ExpectedEnd { get; } + + private readonly PathSegment _segment; + private readonly PathSegment? _nextSegment; + private readonly double _horizDist; + private readonly double _dominantDist; + private readonly float _takeoffYaw; + private readonly float _nominalLandingYaw; + + private int _tickCount; + private Phase _phase = Phase.Approach; + private bool _leftGround; + private bool _carriedGroundEntry; + private bool _releaseForwardLatched; + + private const float YawToleranceDeg = 5f; + private const float MaxYawStepPerTick = 20f; + private const double CarryRunwayThreshold = 0.10; + + internal SidewallParkourController(PathSegment segment, PathSegment? nextSegment) + { + _segment = segment; + _nextSegment = nextSegment; + ExpectedStart = segment.Start; + ExpectedEnd = segment.End; + + double dx = segment.End.X - segment.Start.X; + double dz = segment.End.Z - segment.Start.Z; + _horizDist = Math.Sqrt(dx * dx + dz * dz); + _dominantDist = Math.Max(Math.Abs(dx), Math.Abs(dz)); + double dropHeight = segment.Start.Y - segment.End.Y; + _nominalLandingYaw = TemplateHelper.CalculateYaw(dx, dz); + _takeoffYaw = nextSegment is not null + && _dominantDist >= 5.0 + && dropHeight > 0.0 + && dropHeight < 1.5 + ? TemplateHelper.GetApproachYaw(segment) + : TemplateHelper.GetSidewallTakeoffYaw(segment); + } + + internal TemplateState Tick(Location pos, PlayerPhysics physics, MovementInput input, World world) + { + _tickCount++; + + if (_tickCount == 1 && TemplateHelper.GetHorizontalSpeed(physics) > 0.02) + _carriedGroundEntry = true; + + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + double dy = ExpectedEnd.Y - pos.Y; + double carryApproachProgress = _tickCount == 1 + ? TemplateHelper.ProgressAlongApproach(pos, _segment) + : 0.0; + + if (_phase != Phase.Landing) + { + float activeYaw; + if (_phase == Phase.Approach + && _carriedGroundEntry + && _tickCount == 1 + && carryApproachProgress < CarryRunwayThreshold) + { + activeYaw = TemplateHelper.GetApproachYaw(_segment); + } + else if (_phase == Phase.Airborne && ShouldUseLandingYaw(pos)) + { + activeYaw = GetLandingYaw(pos); + } + else + { + activeYaw = _takeoffYaw; + } + + float yawStep = _phase == Phase.Airborne ? 20f : MaxYawStepPerTick; + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, activeYaw, maxStep: yawStep); + physics.Pitch = TemplateHelper.SmoothPitch( + physics.Pitch, + TemplateHelper.CalculatePitch(dx, dy, dz)); + } + + switch (_phase) + { + case Phase.Approach: + return TickApproach(pos, physics, input, world); + + case Phase.Airborne: + return TickAirborne(pos, physics, input, world); + + case Phase.Landing: + return TickLanding(pos, physics, input, world); + + default: + return TemplateState.Failed; + } + } + + private TemplateState TickApproach(Location pos, PlayerPhysics physics, MovementInput input, World world) + { + if (!physics.OnGround && ShouldRecoverGroundApproach(pos, physics)) + { + physics.OnGround = true; + if (physics.DeltaMovement.Y < 0.0) + physics.DeltaMovement = new Vec3d(physics.DeltaMovement.X, 0.0, physics.DeltaMovement.Z); + } + + if (!physics.OnGround) + { + _leftGround = true; + _phase = Phase.Airborne; + return TickAirborne(pos, physics, input, world); + } + + float yawDelta = YawDifference(physics.Yaw, _takeoffYaw); + bool turnInPlace = yawDelta > 35f; + input.Forward = !turnInPlace; + input.Sprint = !turnInPlace; + + double minApproachDistance = GetMinApproachDistance(); + double approachProgress = TemplateHelper.ProgressAlongApproach(pos, _segment); + double approachSpeed = GetApproachSpeed(physics); + if (_carriedGroundEntry && _tickCount == 1 && approachProgress < CarryRunwayThreshold) + { + input.Sprint = false; + return TemplateState.InProgress; + } + + if (yawDelta < YawToleranceDeg + && approachProgress >= minApproachDistance + && approachSpeed >= GetMinTakeoffApproachSpeed()) + { + if (ShouldApplyLaunchStrafe()) + { + physics.Yaw = TemplateHelper.GetApproachYaw(_segment); + ApplyAirStrafe(physics, input); + } + + if (ShouldSuppressSprintJumpTakeoff()) + input.Sprint = false; + input.Jump = true; + _phase = Phase.Airborne; + } + + if (_tickCount > 40) + return TemplateState.Failed; + + return TemplateState.InProgress; + } + + private TemplateState TickAirborne(Location pos, PlayerPhysics physics, MovementInput input, World world) + { + if (!physics.OnGround) + _leftGround = true; + + bool pastTarget = IsPastTarget(pos); + if (ShouldApplyAirStrafe(pos)) + ApplyAirStrafe(physics, input); + + if (_nextSegment is null) + { + bool shouldRelease = ShouldReleaseInAir(pos, physics, world); + _releaseForwardLatched |= shouldRelease; + + if (_releaseForwardLatched || pastTarget) + { + input.Forward = false; + input.Sprint = false; + } + else + { + input.Forward = true; + input.Sprint = true; + } + } + else + { + switch (ChoosePrepareJumpAirProfile(pos, physics, world)) + { + case PrepareJumpAirProfile.Release: + input.Forward = false; + input.Sprint = false; + break; + + case PrepareJumpAirProfile.Coast: + input.Forward = true; + input.Sprint = false; + break; + + default: + input.Forward = true; + input.Sprint = true; + break; + } + } + + if (_leftGround && physics.OnGround) + { + _phase = Phase.Landing; + return TickLanding(pos, physics, input, world); + } + + if (pos.Y < ExpectedEnd.Y - 4.0 || _tickCount > 60) + return TemplateState.Failed; + + return TemplateState.InProgress; + } + + private bool ShouldApplyAirStrafe(Location pos) + { + if (ExpectedEnd.Y >= ExpectedStart.Y) + return false; + + if (!NeedsAdditionalLateralBias(pos)) + return false; + + if (_dominantDist >= 5.0) + { + double dropHeight = ExpectedStart.Y - ExpectedEnd.Y; + double lateStrafeThreshold = _nextSegment is not null + ? (dropHeight < 1.5 ? 2.30 : 1.55) + : 1.10; + return TemplateHelper.RemainingDistanceAlongSegment(pos, _segment) <= lateStrafeThreshold; + } + + if (_dominantDist < 3.0) + { + double shortDescendThreshold = _nextSegment is not null ? 1.15 : 0.95; + return TemplateHelper.RemainingDistanceAlongSegment(pos, _segment) <= shortDescendThreshold; + } + + double remaining = TemplateHelper.RemainingDistanceAlongSegment(pos, _segment); + double threshold = _dominantDist >= 4.0 + ? Math.Max(2.4, _dominantDist * 0.65) + : Math.Max(1.4, _dominantDist * 0.60); + return remaining <= threshold; + } + + private void ApplyAirStrafe(PlayerPhysics physics, MovementInput input) + { + GetAirLateralDirection(out int desiredX, out int desiredZ); + if (desiredX == 0 && desiredZ == 0) + return; + + double yawRad = physics.Yaw * (Math.PI / 180.0); + double rightX = -Math.Cos(yawRad); + double rightZ = -Math.Sin(yawRad); + double projection = (desiredX * rightX) + (desiredZ * rightZ); + if (projection >= 0.0) + input.Right = true; + else + input.Left = true; + } + + private void GetAirLateralDirection(out int desiredX, out int desiredZ) + { + TemplateHelper.GetApproachHeading(_segment, out int headingX, out int headingZ); + if (headingX != 0) + { + desiredX = 0; + desiredZ = Math.Sign(ExpectedEnd.Z - ExpectedStart.Z); + return; + } + + desiredX = Math.Sign(ExpectedEnd.X - ExpectedStart.X); + desiredZ = 0; + } + + private TemplateState TickLanding(Location pos, PlayerPhysics physics, MovementInput input, World world) + { + if (!physics.OnGround) + { + _phase = Phase.Airborne; + return TickAirborne(pos, physics, input, world); + } + + if (_nextSegment is not null) + return TickPrepareJumpLanding(pos, physics, input, world); + + return TickFinalStopLanding(pos, physics, input, world); + } + + private TemplateState TickPrepareJumpLanding(Location pos, PlayerPhysics physics, MovementInput input, World world) + { + if (!physics.OnGround) + return TemplateState.InProgress; + + if (NeedsFootingRecovery(pos, physics)) + { + ApplyFootingRecovery(pos, physics, input); + if (TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd) + && !TemplateFootingHelper.WillLeaveTargetBlockNextTick(pos, physics, ExpectedEnd)) + { + return TemplateState.Complete; + } + + return ContinueOrFail(pos); + } + + GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world); + if (TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd) + && GroundedSegmentController.ShouldComplete(_segment, pos, physics)) + return TemplateState.Complete; + + return ContinueOrFail(pos); + } + + private TemplateState TickFinalStopLanding(Location pos, PlayerPhysics physics, MovementInput input, World world) + { + if (!physics.OnGround) + { + return ContinueOrFail(pos); + } + + if (NeedsFootingRecovery(pos, physics)) + { + ApplyFootingRecovery(pos, physics, input); + if (TemplateHelper.IsSettledOnTargetBlock(pos, ExpectedEnd, physics)) + return TemplateState.Complete; + return ContinueOrFail(pos); + } + + GroundedSegmentController.Apply(_segment, _nextSegment, pos, physics, input, world); + if (GroundedSegmentController.ShouldComplete(_segment, pos, physics)) + return TemplateState.Complete; + + return ContinueOrFail(pos); + } + + private double GetMinApproachDistance() + { + if (_carriedGroundEntry) + return _dominantDist >= 4.0 ? 0.18 : 0.08; + + if (_dominantDist >= 4.0) + return 0.10; + + return 0.0; + } + + private double GetMinTakeoffApproachSpeed() + { + if (_dominantDist >= 4.0) + return _carriedGroundEntry ? 0.09 : 0.10; + + if (ExpectedEnd.Y != ExpectedStart.Y) + return _carriedGroundEntry ? 0.08 : 0.09; + + if (_dominantDist >= 3.0) + return _carriedGroundEntry ? 0.09 : 0.08; + + return _carriedGroundEntry ? 0.06 : 0.05; + } + + private double GetApproachSpeed(PlayerPhysics physics) + { + TemplateHelper.GetApproachHeading(_segment, out int headingX, out int headingZ); + return Math.Max(0.0, TemplateHelper.ProjectHorizontalSpeedAlongHeading(physics, headingX, headingZ)); + } + + private bool ShouldSuppressSprintJumpTakeoff() + { + if (_dominantDist >= 3.0) + return false; + + if (ExpectedEnd.Y < ExpectedStart.Y) + return true; + + return _nextSegment is null + && ExpectedEnd.Y == ExpectedStart.Y; + } + + private bool ShouldApplyLaunchStrafe() + { + return IsShallowLongDescendingPrepareJump(); + } + + private bool IsShallowLongDescendingPrepareJump() + { + double dropHeight = ExpectedStart.Y - ExpectedEnd.Y; + return _nextSegment is not null + && _dominantDist >= 5.0 + && dropHeight > 0.0 + && dropHeight < 1.5; + } + + private bool NeedsAdditionalLateralBias(Location pos) + { + double halfWidth = PhysicsConsts.PlayerWidth / 2.0; + double blockMinX = Math.Floor(ExpectedEnd.X); + double blockMaxX = blockMinX + 1.0; + double blockMinZ = Math.Floor(ExpectedEnd.Z); + double blockMaxZ = blockMinZ + 1.0; + + TemplateHelper.GetApproachHeading(_segment, out int headingX, out int headingZ); + if (headingZ != 0) + return pos.X - halfWidth < blockMinX || pos.X + halfWidth > blockMaxX; + + return pos.Z - halfWidth < blockMinZ || pos.Z + halfWidth > blockMaxZ; + } + + private bool ShouldRecoverGroundApproach(Location pos, PlayerPhysics physics) + { + if (pos.Y < ExpectedStart.Y - 0.05 || pos.Y > ExpectedStart.Y + 0.05) + return false; + + if (Math.Abs(physics.DeltaMovement.Y) > 0.12) + return false; + + return TemplateFootingHelper.IsCenterInsideTargetBlock(pos, ExpectedStart); + } + + private bool ShouldUseLandingYaw(Location pos) + { + double approachProgress = TemplateHelper.ProgressAlongApproach(pos, _segment); + double activationProgress; + if (_carriedGroundEntry) + { + activationProgress = 0.15; + } + else if (_horizDist <= 2.5) + { + activationProgress = 0.30; + } + else if (_horizDist <= 3.5) + { + activationProgress = 0.45; + } + else + { + activationProgress = 0.60; + } + + if (ExpectedEnd.Y > ExpectedStart.Y) + activationProgress += 0.10; + else if (ExpectedEnd.Y < ExpectedStart.Y) + { + activationProgress = Math.Max(0.20, activationProgress - 0.10); + if (_nextSegment is not null && _dominantDist < 3.0) + { + // Keep the short descending prepare-jump takeoff yaw longer so the late air + // strafe can shave south carry instead of rotating into a south-biased drift. + activationProgress = Math.Max(activationProgress, _dominantDist - 0.55); + } + + if (_nextSegment is not null && _dominantDist >= 5.0) + { + double dropHeight = ExpectedStart.Y - ExpectedEnd.Y; + + // Long descending sidewall jumps only need a small west bias at entry. If we + // rotate into landing yaw too early, we hit the landing block's north face + // before we have enough south depth to climb onto the top. + activationProgress = Math.Max( + activationProgress, + dropHeight < 1.5 ? _dominantDist - 0.35 : _dominantDist - 0.35); + } + } + else if (_dominantDist >= 4.0) + activationProgress = Math.Max(0.45, activationProgress - 0.10); + + return approachProgress >= activationProgress; + } + + private float GetLandingYaw(Location pos) + { + double dx = ExpectedEnd.X - pos.X; + double dz = ExpectedEnd.Z - pos.Z; + if ((dx * dx) + (dz * dz) < 1.0E-6) + return _nominalLandingYaw; + + return TemplateHelper.CalculateYaw(dx, dz); + } + + private bool NeedsFootingRecovery(Location pos, PlayerPhysics physics) + { + if (!physics.OnGround) + return false; + + if (TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd)) + return TemplateFootingHelper.WillLeaveTargetBlockNextTick(pos, physics, ExpectedEnd); + + if (TemplateFootingHelper.IsCenterInsideTargetBlock(pos, ExpectedEnd)) + return true; + + if (_nextSegment is null + && ExpectedEnd.Y < ExpectedStart.Y + && _segment.ParkourProfile == ParkourProfile.Sidewall + && TemplateHelper.HorizontalDistanceSq(pos, ExpectedEnd) <= 0.81) + { + return true; + } + + return TemplateHelper.HorizontalDistanceSq(pos, ExpectedEnd) <= 0.49; + } + + private void ApplyFootingRecovery(Location pos, PlayerPhysics physics, MovementInput input) + { + float recoveryYaw = GetLandingYaw(pos); + float yawDelta = YawDifference(physics.Yaw, recoveryYaw); + physics.DeltaMovement = new Vec3d(0.0, physics.DeltaMovement.Y, 0.0); + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, recoveryYaw, maxStep: MaxYawStepPerTick); + + input.Forward = yawDelta <= 12f; + input.Sprint = false; + input.Back = false; + input.Left = false; + input.Right = false; + } + + private bool IsPastTarget(Location pos) + { + double dirX = ExpectedEnd.X - ExpectedStart.X; + double dirZ = ExpectedEnd.Z - ExpectedStart.Z; + double len = Math.Sqrt(dirX * dirX + dirZ * dirZ); + if (len < 0.001) + return false; + + dirX /= len; + dirZ /= len; + + double relX = pos.X - ExpectedEnd.X; + double relZ = pos.Z - ExpectedEnd.Z; + return relX * dirX + relZ * dirZ > 0.0; + } + + private TemplateState ContinueOrFail(Location pos) + { + if (pos.Y < ExpectedEnd.Y - 4.0 || _tickCount > 60) + return TemplateState.Failed; + + return TemplateState.InProgress; + } + + private bool ShouldReleaseInAir(Location pos, PlayerPhysics physics, World world) + { + bool heuristicRelease = _segment.ExitTransition == PathTransitionType.FinalStop + && ShouldReleaseByRemainingLead(pos, physics); + if (heuristicRelease) + return true; + + Location? landingIfHolding = PredictLandingPosition(physics, world, holdForward: true, holdSprint: true); + Location? landingIfReleased = PredictLandingPosition(physics, world, holdForward: false, holdSprint: false); + if (landingIfHolding is null || landingIfReleased is null) + return false; + + bool holdingStaysInside = TemplateFootingHelper.IsFootprintInsideTargetBlock(landingIfHolding.Value, ExpectedEnd); + bool releasingStaysInside = TemplateFootingHelper.IsFootprintInsideTargetBlock(landingIfReleased.Value, ExpectedEnd); + return !holdingStaysInside && releasingStaysInside; + } + + private PrepareJumpAirProfile ChoosePrepareJumpAirProfile(Location pos, PlayerPhysics physics, World world) + { + double remaining = TemplateHelper.RemainingDistanceAlongSegment(pos, _segment); + double releaseThreshold = _dominantDist >= 4.0 ? 1.05 : 0.55; + double coastThreshold = _dominantDist >= 4.0 ? 1.50 : 0.90; + + if (ExpectedEnd.Y < ExpectedStart.Y) + { + if (_dominantDist < 3.0) + { + releaseThreshold += 0.75; + coastThreshold += 0.75; + } + else if (_dominantDist >= 5.0) + { + double dropHeight = ExpectedStart.Y - ExpectedEnd.Y; + if (dropHeight < 1.5) + { + releaseThreshold = Math.Max(0.50, releaseThreshold - 0.55); + coastThreshold = Math.Max(0.85, coastThreshold - 0.65); + } + else + { + releaseThreshold = Math.Max(0.60, releaseThreshold - 0.45); + coastThreshold = Math.Max(0.95, coastThreshold - 0.45); + } + } + else + { + releaseThreshold += 0.20; + coastThreshold += 0.25; + } + } + + if (remaining <= releaseThreshold) + return PrepareJumpAirProfile.Release; + + if (remaining <= coastThreshold) + return PrepareJumpAirProfile.Coast; + + Location? landingIfHolding = PredictLandingPosition(physics, world, holdForward: true, holdSprint: true); + Location? landingIfCoasting = PredictLandingPosition(physics, world, holdForward: true, holdSprint: false); + Location? landingIfReleased = PredictLandingPosition(physics, world, holdForward: false, holdSprint: false); + + if (landingIfHolding is null || landingIfCoasting is null || landingIfReleased is null) + return PrepareJumpAirProfile.Hold; + + bool holdingStaysInside = TemplateFootingHelper.IsFootprintInsideTargetBlock(landingIfHolding.Value, ExpectedEnd); + bool coastingStaysInside = TemplateFootingHelper.IsFootprintInsideTargetBlock(landingIfCoasting.Value, ExpectedEnd); + bool releasingStaysInside = TemplateFootingHelper.IsFootprintInsideTargetBlock(landingIfReleased.Value, ExpectedEnd); + + if (holdingStaysInside) + return PrepareJumpAirProfile.Hold; + + if (coastingStaysInside) + return PrepareJumpAirProfile.Coast; + + if (releasingStaysInside) + return PrepareJumpAirProfile.Release; + + double holdDistance = TemplateHelper.HorizontalDistanceSq(landingIfHolding.Value, ExpectedEnd); + double coastDistance = TemplateHelper.HorizontalDistanceSq(landingIfCoasting.Value, ExpectedEnd); + double releaseDistance = TemplateHelper.HorizontalDistanceSq(landingIfReleased.Value, ExpectedEnd); + if (coastDistance <= holdDistance && coastDistance <= releaseDistance) + return PrepareJumpAirProfile.Coast; + + return releaseDistance < holdDistance + ? PrepareJumpAirProfile.Release + : PrepareJumpAirProfile.Hold; + } + + 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 static Location? PredictLandingPosition(PlayerPhysics physics, World world, bool holdForward, bool holdSprint) + { + PlayerPhysics sim = TemplateHelper.ClonePhysicsForPlanning(physics); + var input = new MovementInput + { + Forward = holdForward, + Sprint = holdSprint + }; + + for (int tick = 0; tick < 16; tick++) + { + sim.ApplyInput(input); + sim.Tick(world); + if (sim.OnGround) + return new Location(sim.Position.X, sim.Position.Y, sim.Position.Z); + } + + return null; + } + + private static float YawDifference(float current, float target) + { + float delta = target - current; + while (delta > 180f) delta -= 360f; + while (delta < -180f) delta += 360f; + return Math.Abs(delta); + } + + } +} diff --git a/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs b/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs index c1b87a28..765b25d7 100644 --- a/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs +++ b/MinecraftClient/Pathing/Moves/ParkourFeasibility.cs @@ -69,6 +69,47 @@ internal static class ParkourFeasibility return IsColumnPassable(ctx, backX, y, backZ); } + public static bool TryGetRequiredStaticEntryRunupSteps( + MoveType previousMoveType, + int xOffset, + int zOffset, + int yDelta, + out int requiredSteps) + { + requiredSteps = 0; + + if (previousMoveType is MoveType.Parkour or MoveType.Descend) + return false; + + int major = Math.Max(Math.Abs(xOffset), Math.Abs(zOffset)); + if (yDelta == -1 && major == 5) + { + requiredSteps = 1; + return true; + } + + return false; + } + + public static bool HasPreparedRunup( + EntryPreparationState state, + int x, + int y, + int z, + int forwardX, + int forwardZ, + int requiredSteps) + { + return state.Kind == EntryPreparationKind.SidewallRunup + && state.IsPrepared + && state.OriginX == x + && state.OriginY == y + && state.OriginZ == z + && state.ForwardX == forwardX + && state.ForwardZ == forwardZ + && state.RequiredSteps == requiredSteps; + } + public static bool HasDiagonalShoulderClearance( CalculationContext ctx, int x, @@ -201,6 +242,21 @@ internal static class ParkourFeasibility if (carriedEntry) return true; + // Cold-start sprint-jump reaches ~3.1-3.5 blocks horizontally without + // any pre-existing momentum, so short sidewall jumps remain feasible + // from a lone overhang block even when no 2-block runway is available + // behind the start (matches the staircase/step-pyramid cases seen in + // the wild, and Baritone's MomentumBehavior.ALLOWED contract). + double horiz = Math.Sqrt((xOffset * xOffset) + (zOffset * zOffset)); + double coldStartReach = yDelta switch + { + > 0 => 2.5, + < 0 => 3.3, + _ => 3.2, + }; + if (horiz <= coldStartReach) + return true; + for (int i = 1; i <= 2; i++) { int rx = x - (forwardX * i); diff --git a/docs/superpowers/plans/2026-04-18-sidewall-parkour-zero-replan.md b/docs/superpowers/plans/2026-04-18-sidewall-parkour-zero-replan.md new file mode 100644 index 00000000..7ff3fec1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-sidewall-parkour-zero-replan.md @@ -0,0 +1,1035 @@ +# Sidewall Parkour 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 all `sidewall` cases in `tools/test-parkour.py --filter sidewall --parallel 6 --version 1.21.11-Vanilla` match theory on `1.21.11-Vanilla`, with every accepted case completing at `replan_count=0` and `turn_stall_count=0`, while preserving the current all-green `linear` matrix. + +**Architecture:** Isolate sidewall behavior instead of loosening the generic linear parkour logic. Add an explicit `ParkourProfile.Sidewall` planner-to-executor profile that the planner can admit using dominant-axis runway rules and the executor can follow using a straight runway approach plus controlled in-air bias toward the landing block. Freeze the exact `tools/test-parkour.py` sidewall geometry in .NET regressions first, then implement planner and executor changes behind those tests, and do not call the work done until `sidewall` is `30/30` and `linear` remains `22/22`. + +**Tech Stack:** C# 14 / .NET 10, xUnit, MCC pathing core/execution, Python 3 live harness `tools/test-parkour.py`, local `1.21.11-Vanilla` server via `tools/mcc-env.sh`. + +--- + +## Scope And Guardrails + +- In scope: `sidewall/flat`, `sidewall/ascend`, and `sidewall/descend` for `wo=0` and `wo=1`, using the exact live geometry from `tools/test-parkour.py`. +- Hard requirement: every accepted sidewall case must finish with `replan_count=0` and `turn_stall_count=0`. +- Hard requirement: `linear` is already fully green in live runs. Do not weaken or rewrite the existing cardinal linear parkour rules just to make sidewall pass. +- Acceptance gate for this plan: targeted green .NET regressions plus `tools/test-parkour.py` sidewall and linear live matrices. Do not use the current full `MinecraftClient.Tests` suite as the gate because the baseline is already `181/198` with 17 unrelated failures. +- Execution note: the fresh baseline evidence came from branch `pathing/jump-entry-direct-yaw` in the main workspace, not an isolated worktree. If the user keeps work in this workspace, do not reset or discard unrelated changes. +- Out of scope for this plan: `neo` and `ceiling` live mismatches. If a helper becomes reusable for those families later, keep it generic, but do not expand verification targets in this plan. + +## File Structure + +- Create: `MinecraftClient/Pathing/Core/ParkourProfile.cs` + Responsibility: explicit planner-to-executor profile for `Default` vs `Sidewall` parkour. +- Modify: `MinecraftClient/Pathing/Core/MoveResult.cs` + Responsibility: carry `ParkourProfile` out of `IMove.Calculate()`. +- Modify: `MinecraftClient/Pathing/Core/PathNode.cs` + Responsibility: remember which parkour profile produced each node. +- Modify: `MinecraftClient/Pathing/Execution/PathSegment.cs` + Responsibility: expose per-segment `ParkourProfile`. +- Modify: `MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs` + Responsibility: thread `ParkourProfile` from planned node to runtime segment. +- Create: `MinecraftClient/Pathing/Moves/Impl/MoveSidewallParkour.cs` + Responsibility: sidewall-specific admissibility with dominant-axis run-up and wall-adjacent arc rules. +- Modify: `MinecraftClient/Pathing/Moves/ParkourFeasibility.cs` + Responsibility: shared helpers for dominant-axis runway checks, inside-wall depth validation, and sidewall landing clearance. +- Modify: `MinecraftClient/Pathing/Core/AStarPathFinder.cs` + Responsibility: register the full sidewall candidate set without disturbing current linear/cardinal move coverage. +- Modify: `MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs` + Responsibility: explicitly tag generic parkour as `ParkourProfile.Default`; do not change its linear admissibility rules. +- Modify: `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs` + Responsibility: compute sidewall approach heading and dominant-axis progress. +- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` + Responsibility: use the sidewall approach heading on the runway, then rotate toward the landing/exit heading in air without inducing turn stalls or replans. +- Create: `MinecraftClient.Tests/Pathing/Execution/Scenarios/SidewallParkourScenarioBuilder.cs` + Responsibility: exact in-memory world builder matching `tools/test-parkour.py::WorldBuilder.build_sidewall_route()`. +- Create: `MinecraftClient.Tests/Pathing/Execution/SidewallParkourScenarioBuilderTests.cs` + Responsibility: assert the in-memory builder matches live-harness geometry and endpoints. +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs` + Responsibility: assert `ParkourProfile` survives path-to-segment translation. +- Create: `MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs` + Responsibility: direct planner admissibility tests for theory-allowed and theory-forbidden sidewall jumps. +- Modify: `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs` + Responsibility: exact live-coordinate sidewall planner regressions matching the harness. +- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` + Responsibility: sidewall template convergence and no-spin regressions. +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs` + Responsibility: accepted sidewall chains complete with `0 replan`. +- Use for verification only: `tools/test-parkour.py` + Responsibility: parallel live matrix verification, not production code changes. + +### Task 1: Mirror The Live Sidewall Geometry In Test Fixtures + +**Files:** +- Create: `MinecraftClient.Tests/Pathing/Execution/Scenarios/SidewallParkourScenarioBuilder.cs` +- Create: `MinecraftClient.Tests/Pathing/Execution/SidewallParkourScenarioBuilderTests.cs` + +- [ ] **Step 1: Write the failing builder tests** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/SidewallParkourScenarioBuilderTests.cs +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Goals; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class SidewallParkourScenarioBuilderTests +{ + [Fact] + public void BuildWorld_FlatGap2Wo0_MatchesLiveRouteGeometry() + { + World world = SidewallParkourScenarioBuilder.BuildWorld(gap: 2, deltaY: 0, wallOffset: 0); + + Assert.Equal(Material.Stone, world.GetBlock(new Location(100, 79, 98)).Type); + Assert.Equal(Material.Stone, world.GetBlock(new Location(100, 79, 99)).Type); + Assert.Equal(Material.Stone, world.GetBlock(new Location(100, 79, 100)).Type); + Assert.Equal(Material.Stone, world.GetBlock(new Location(99, 78, 100)).Type); + Assert.Equal(Material.Stone, world.GetBlock(new Location(99, 79, 102)).Type); + Assert.Equal(Material.Air, world.GetBlock(new Location(100, 79, 101)).Type); + } + + [Fact] + public void BuildWorld_FlatGap3Wo1_ExtendsWallByTwoBlocksAlongRunwaySide() + { + World world = SidewallParkourScenarioBuilder.BuildWorld(gap: 3, deltaY: 0, wallOffset: 1); + + Assert.Equal(Material.Stone, world.GetBlock(new Location(99, 78, 100)).Type); + Assert.Equal(Material.Stone, world.GetBlock(new Location(99, 78, 101)).Type); + Assert.Equal(Material.Air, world.GetBlock(new Location(99, 78, 102)).Type); + Assert.Equal(Material.Stone, world.GetBlock(new Location(99, 79, 103)).Type); + } + + [Fact] + public void Create_FlatGap2Wo0_UsesSameStartAndGoalAsLiveHarness() + { + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create( + "sidewall-flat-gap2-wo0", + gap: 2, + deltaY: 0, + wallOffset: 0); + + Assert.Equal(new Location(100.5, 80, 100.5), scenario.Start); + Assert.Equal(97, scenario.Goal.X); + Assert.Equal(80, scenario.Goal.Y); + Assert.Equal(106, scenario.Goal.Z); + Assert.Equal(0f, scenario.StartYaw); + } +} +``` + +- [ ] **Step 2: Run the builder tests to verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter FullyQualifiedName~SidewallParkourScenarioBuilderTests -v minimal +``` + +Expected: FAIL with missing-type errors for `SidewallParkourScenarioBuilder`. + +- [ ] **Step 3: Implement the exact sidewall scenario builder** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/Scenarios/SidewallParkourScenarioBuilder.cs +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Goals; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public static class SidewallParkourScenarioBuilder +{ + private const int SegmentCount = 3; + private const int BaseX = 100; + private const int BaseY = 80; + private const int BaseZ = 100; + private const int FloorY = BaseY - 1; + + public static IEnumerable AcceptedCases() + { + yield return ["sidewall-flat-gap2-wo0", 2, 0, 0]; + yield return ["sidewall-flat-gap3-wo1", 3, 0, 1]; + yield return ["sidewall-ascend-gap2-dy+1-wo0", 2, 1, 0]; + yield return ["sidewall-ascend-gap3-dy+1-wo1", 3, 1, 1]; + yield return ["sidewall-descend-gap2-dy-1-wo0", 2, -1, 0]; + yield return ["sidewall-descend-gap3-dy-1-wo1", 3, -1, 1]; + yield return ["sidewall-descend-gap2-dy-2-wo0", 2, -2, 0]; + yield return ["sidewall-descend-gap3-dy-2-wo1", 3, -2, 1]; + } + + public static IEnumerable RejectedCases() + { + yield return ["sidewall-flat-gap5-wo0", 5, 0, 0]; + yield return ["sidewall-flat-gap5-wo1", 5, 0, 1]; + yield return ["sidewall-ascend-gap4-dy+1-wo0", 4, 1, 0]; + yield return ["sidewall-ascend-gap4-dy+1-wo1", 4, 1, 1]; + yield return ["sidewall-descend-gap6-dy-1-wo0", 6, -1, 0]; + yield return ["sidewall-descend-gap6-dy-1-wo1", 6, -1, 1]; + yield return ["sidewall-descend-gap6-dy-2-wo0", 6, -2, 0]; + yield return ["sidewall-descend-gap6-dy-2-wo1", 6, -2, 1]; + } + + internal static PathingExecutionScenario Create(string scenarioId, int gap, int deltaY, int wallOffset, int maxExecutionTicks = 700) + { + int endFloorY = FloorY + (deltaY * SegmentCount); + int endX = BaseX - SegmentCount; + int endZ = BaseZ + (gap * SegmentCount); + + return new PathingExecutionScenario + { + Id = scenarioId, + BuildWorld = () => BuildWorld(gap, deltaY, wallOffset), + Start = new Location(BaseX + 0.5, BaseY, BaseZ + 0.5), + Goal = new GoalBlock(endX, endFloorY + 1, endZ), + StartYaw = 0f, + MaxExecutionTicks = maxExecutionTicks, + }; + } + + internal static World BuildWorld(int gap, int deltaY, int wallOffset) + { + int maxZ = BaseZ + gap * SegmentCount + 8; + World world = FlatWorldTestBuilder.CreateStoneFloor(floorY: 0, min: 80, max: maxZ + 8); + FlatWorldTestBuilder.ClearBox(world, 90, 70, 90, 110, 96, maxZ + 8); + + int curX = BaseX; + int curY = FloorY; + int curZ = BaseZ; + + FlatWorldTestBuilder.FillSolid(world, curX, curY, curZ - 2, curX, curY, curZ); + + for (int segment = 0; segment < SegmentCount; segment++) + { + int wallX = curX - 1; + int wallZEnd = curZ + wallOffset; + int landX = curX - 1; + int landY = curY + deltaY; + int landZ = curZ + gap; + + FlatWorldTestBuilder.FillSolid( + world, + wallX, + Math.Min(curY, landY) - 1, + curZ, + wallX, + Math.Max(curY, landY) + 7, + wallZEnd); + FlatWorldTestBuilder.SetSolid(world, landX, landY, landZ); + + curX = landX; + curY = landY; + curZ = landZ; + } + + return world; + } +} +``` + +- [ ] **Step 4: Run the builder tests to verify they pass** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter FullyQualifiedName~SidewallParkourScenarioBuilderTests -v minimal +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add MinecraftClient.Tests/Pathing/Execution/Scenarios/SidewallParkourScenarioBuilder.cs \ + MinecraftClient.Tests/Pathing/Execution/SidewallParkourScenarioBuilderTests.cs +git commit -m "test: add sidewall scenario builder fixtures" +``` + +--- + +### Task 2: Thread `ParkourProfile` From Planner Nodes To Runtime Segments + +**Files:** +- Create: `MinecraftClient/Pathing/Core/ParkourProfile.cs` +- Modify: `MinecraftClient/Pathing/Core/MoveResult.cs` +- Modify: `MinecraftClient/Pathing/Core/PathNode.cs` +- Modify: `MinecraftClient/Pathing/Execution/PathSegment.cs` +- Modify: `MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs` +- Modify: `MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs` + +- [ ] **Step 1: Write the failing profile-plumbing test** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs +[Fact] +public void FromPath_CopiesParkourProfile_ToRuntimeSegment() +{ + var start = new PathNode(100, 80, 100); + var end = new PathNode(99, 80, 102) + { + MoveUsed = MoveType.Parkour, + ParkourProfile = ParkourProfile.Sidewall + }; + + List segments = PathSegmentBuilder.FromPath([start, end]); + + Assert.Single(segments); + Assert.Equal(ParkourProfile.Sidewall, segments[0].ParkourProfile); +} +``` + +- [ ] **Step 2: Run the profile-plumbing test to verify it fails** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter FullyQualifiedName~FromPath_CopiesParkourProfile_ToRuntimeSegment -v minimal +``` + +Expected: FAIL with missing members such as `ParkourProfile` on `PathNode`, `MoveResult`, and `PathSegment`. + +- [ ] **Step 3: Add the profile enum and thread it through planner/runtime data structures** + +```csharp +// MinecraftClient/Pathing/Core/ParkourProfile.cs +namespace MinecraftClient.Pathing.Core +{ + public enum ParkourProfile + { + None = 0, + Default = 1, + Sidewall = 2 + } +} +``` + +```csharp +// MinecraftClient/Pathing/Core/MoveResult.cs +public struct MoveResult +{ + public int DestX; + public int DestY; + public int DestZ; + public double Cost; + public ParkourProfile ParkourProfile; + + public void Set(int x, int y, int z, double cost, ParkourProfile parkourProfile = ParkourProfile.None) + { + DestX = x; + DestY = y; + DestZ = z; + Cost = cost; + ParkourProfile = parkourProfile; + } + + public void SetImpossible() + { + Cost = ActionCosts.CostInf; + ParkourProfile = ParkourProfile.None; + } +} +``` + +```csharp +// MinecraftClient/Pathing/Core/PathNode.cs +public sealed class PathNode +{ + // existing fields... + public MoveType MoveUsed; + public ParkourProfile ParkourProfile; +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/PathSegment.cs +public sealed class PathSegment +{ + public required Location Start { get; init; } + public required Location End { get; init; } + public required MoveType MoveType { get; init; } + public ParkourProfile ParkourProfile { get; init; } = ParkourProfile.None; + // existing properties... +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs +private static PathSegment CreatePreview(PathNode start, PathNode end) +{ + return new PathSegment + { + Start = new Location(start.X + 0.5, start.Y, start.Z + 0.5), + End = new Location(end.X + 0.5, end.Y, end.Z + 0.5), + MoveType = end.MoveUsed, + ParkourProfile = end.ParkourProfile + }; +} +``` + +```csharp +// MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs +result.Set(destX, destY, destZ, cost, ParkourProfile.Default); +``` + +- [ ] **Step 4: Run the profile-plumbing test to verify it passes** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~FromPath_CopiesParkourProfile_ToRuntimeSegment|FullyQualifiedName~FromPath_AnnotatesTraverseIntoParkour_AsPrepareJump" -v minimal +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add MinecraftClient/Pathing/Core/ParkourProfile.cs \ + MinecraftClient/Pathing/Core/MoveResult.cs \ + MinecraftClient/Pathing/Core/PathNode.cs \ + MinecraftClient/Pathing/Execution/PathSegment.cs \ + MinecraftClient/Pathing/Execution/PathSegmentBuilder.cs \ + MinecraftClient/Pathing/Moves/Impl/MoveParkour.cs \ + MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs +git commit -m "refactor: thread parkour profile into runtime segments" +``` + +--- + +### Task 3: Implement Planner Support For Sidewall Parkour + +**Files:** +- Create: `MinecraftClient/Pathing/Moves/Impl/MoveSidewallParkour.cs` +- Create: `MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs` +- Modify: `MinecraftClient/Pathing/Moves/ParkourFeasibility.cs` +- Modify: `MinecraftClient/Pathing/Core/AStarPathFinder.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs` + +- [ ] **Step 1: Write the failing planner tests for exact theory-allowed and theory-forbidden cases** + +```csharp +// MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Moves.Impl; +using MinecraftClient.Tests.Pathing.Execution; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Moves; + +public sealed class MoveSidewallParkourTests +{ + [Theory] + [InlineData("sidewall-flat-gap2-wo0", 2, 0, 0)] + [InlineData("sidewall-flat-gap3-wo1", 3, 0, 1)] + [InlineData("sidewall-ascend-gap2-dy+1-wo0", 2, 1, 0)] + [InlineData("sidewall-ascend-gap3-dy+1-wo1", 3, 1, 1)] + [InlineData("sidewall-descend-gap2-dy-1-wo0", 2, -1, 0)] + [InlineData("sidewall-descend-gap3-dy-1-wo1", 3, -1, 1)] + [InlineData("sidewall-descend-gap2-dy-2-wo0", 2, -2, 0)] + [InlineData("sidewall-descend-gap3-dy-2-wo1", 3, -2, 1)] + public void Calculate_AcceptsTheoryAllowedCases(string scenarioId, int gap, int deltaY, int wallOffset) + { + World world = SidewallParkourScenarioBuilder.BuildWorld(gap, deltaY, wallOffset); + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var move = new MoveSidewallParkour(xOffset: -1, zOffset: gap, yDelta: deltaY); + MoveResult result = default; + + move.Calculate(ctx, 100, 80, 100, ref result); + + Assert.False(result.IsImpossible); + Assert.Equal(ParkourProfile.Sidewall, result.ParkourProfile); + } + + [Theory] + [InlineData("sidewall-flat-gap5-wo0", 5, 0, 0)] + [InlineData("sidewall-flat-gap5-wo1", 5, 0, 1)] + [InlineData("sidewall-ascend-gap4-dy+1-wo0", 4, 1, 0)] + [InlineData("sidewall-ascend-gap4-dy+1-wo1", 4, 1, 1)] + [InlineData("sidewall-descend-gap6-dy-1-wo0", 6, -1, 0)] + [InlineData("sidewall-descend-gap6-dy-2-wo1", 6, -2, 1)] + public void Calculate_RejectsTheoryForbiddenCases(string scenarioId, int gap, int deltaY, int wallOffset) + { + World world = SidewallParkourScenarioBuilder.BuildWorld(gap, deltaY, wallOffset); + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var move = new MoveSidewallParkour(xOffset: -1, zOffset: gap, yDelta: deltaY); + MoveResult result = default; + + move.Calculate(ctx, 100, 80, 100, ref result); + + Assert.True(result.IsImpossible); + } +} +``` + +```csharp +// MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs +[Theory] +[MemberData(nameof(SidewallParkourScenarioBuilder.AcceptedCases), MemberType = typeof(SidewallParkourScenarioBuilder))] +public void AStar_SidewallAcceptedCases_PlanThroughAllThreeJumps(string scenarioId, int gap, int deltaY, int wallOffset) +{ + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create(scenarioId, gap, deltaY, wallOffset); + PathResult result = PathingScenarioRunner.PlanOnly(scenario); + List segments = PathSegmentBuilder.FromPath(result.Path); + + Assert.Equal(PathStatus.Success, result.Status); + Assert.Equal(3, segments.FindAll(segment => segment.MoveType == MoveType.Parkour).Count); + Assert.All(segments, segment => + { + if (segment.MoveType == MoveType.Parkour) + Assert.Equal(ParkourProfile.Sidewall, segment.ParkourProfile); + }); + Assert.Equal(scenario.Goal.X + 0.5, segments[^1].End.X); + Assert.Equal(scenario.Goal.Y, segments[^1].End.Y); + Assert.Equal(scenario.Goal.Z + 0.5, segments[^1].End.Z); +} + +[Theory] +[MemberData(nameof(SidewallParkourScenarioBuilder.RejectedCases), MemberType = typeof(SidewallParkourScenarioBuilder))] +public void AStar_SidewallRejectedCases_RejectBeforeExecution(string scenarioId, int gap, int deltaY, int wallOffset) +{ + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create(scenarioId, gap, deltaY, wallOffset); + PathResult result = PathingScenarioRunner.PlanOnly(scenario); + + Assert.Equal(PathStatus.Failed, result.Status); + Assert.Empty(PathSegmentBuilder.FromPath(result.Path)); +} +``` + +- [ ] **Step 2: Run the planner tests to verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~MoveSidewallParkourTests|FullyQualifiedName~AStar_Sidewall" -v minimal +``` + +Expected: FAIL because `MoveSidewallParkour` does not exist yet and the planner currently has no sidewall candidate family. + +- [ ] **Step 3: Implement a dedicated sidewall move and register the full candidate table** + +```csharp +// MinecraftClient/Pathing/Moves/Impl/MoveSidewallParkour.cs +using System; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Pathing.Moves.Impl +{ + public sealed class MoveSidewallParkour : IMove + { + public MoveType Type => MoveType.Parkour; + public int XOffset { get; } + public int ZOffset { get; } + public bool DynamicY => false; + + private readonly int _yDelta; + + public MoveSidewallParkour(int xOffset, int zOffset, int yDelta = 0) + { + XOffset = xOffset; + ZOffset = zOffset; + _yDelta = yDelta; + } + + public void Calculate(CalculationContext ctx, int x, int y, int z, ref MoveResult result) + { + if (!ctx.AllowParkour || !ctx.CanSprint) + { + result.SetImpossible(); + return; + } + + if (!ParkourFeasibility.IsSidewallProfile(XOffset, ZOffset, _yDelta)) + { + result.SetImpossible(); + return; + } + + ParkourFeasibility.GetSidewallAxes(XOffset, ZOffset, out int forwardX, out int forwardZ, out int lateralX, out int lateralZ); + + int destX = x + XOffset; + int destY = y + _yDelta; + int destZ = z + ZOffset; + + if (!ctx.CanWalkThrough(x, y + 2, z)) + { + result.SetImpossible(); + return; + } + + if (!ParkourFeasibility.HasDominantAxisRunUp(ctx, x, y, z, forwardX, forwardZ, XOffset, ZOffset, _yDelta)) + { + result.SetImpossible(); + return; + } + + if (!ParkourFeasibility.HasSidewallArcClearance(ctx, x, y, z, forwardX, forwardZ, lateralX, lateralZ, XOffset, ZOffset, _yDelta)) + { + result.SetImpossible(); + return; + } + + if (!ParkourFeasibility.HasSidewallLandingClearance(ctx, destX, destY, destZ, forwardX, forwardZ, lateralX, lateralZ)) + { + result.SetImpossible(); + return; + } + + double horizDist = Math.Sqrt((double)(XOffset * XOffset + ZOffset * ZOffset)); + double cost = _yDelta switch + { + > 0 => horizDist * ctx.SprintCost + ctx.JumpPenalty * 2, + < 0 => horizDist * ctx.SprintCost + ctx.JumpPenalty + ActionCosts.FallCost(-_yDelta), + _ => horizDist * ctx.SprintCost + ctx.JumpPenalty, + }; + + result.Set(destX, destY, destZ, cost, ParkourProfile.Sidewall); + } + } +} +``` + +```csharp +// MinecraftClient/Pathing/Moves/ParkourFeasibility.cs +internal static bool IsSidewallProfile(int xOffset, int zOffset, int yDelta) +{ + int absX = Math.Abs(xOffset); + int absZ = Math.Abs(zOffset); + int major = Math.Max(absX, absZ); + int minor = Math.Min(absX, absZ); + + return minor == 1 + && major >= 2 + && major <= 5 + && yDelta is >= -2 and <= 1; +} + +internal static void GetSidewallAxes(int xOffset, int zOffset, out int forwardX, out int forwardZ, out int lateralX, out int lateralZ) +{ + if (Math.Abs(xOffset) > Math.Abs(zOffset)) + { + forwardX = Math.Sign(xOffset); + forwardZ = 0; + lateralX = 0; + lateralZ = Math.Sign(zOffset); + } + else + { + forwardX = 0; + forwardZ = Math.Sign(zOffset); + lateralX = Math.Sign(xOffset); + lateralZ = 0; + } +} + +internal static bool HasDominantAxisRunUp(CalculationContext ctx, int x, int y, int z, int forwardX, int forwardZ, int xOffset, int zOffset, int yDelta) +{ + int requiredBlocks = yDelta switch + { + > 0 => 2, + < 0 when Math.Max(Math.Abs(xOffset), Math.Abs(zOffset)) >= 5 => 2, + < 0 => 1, + _ when Math.Max(Math.Abs(xOffset), Math.Abs(zOffset)) >= 4 => 2, + _ => 1, + }; + + for (int i = 1; i <= requiredBlocks; i++) + { + int rx = x - forwardX * i; + int rz = z - forwardZ * i; + if (!ctx.CanWalkOn(rx, y - 1, rz) || !IsColumnPassable(ctx, rx, y, rz)) + return false; + } + + return true; +} + +internal static bool HasSidewallArcClearance(CalculationContext ctx, int x, int y, int z, int forwardX, int forwardZ, int lateralX, int lateralZ, int xOffset, int zOffset, int yDelta) +{ + int major = Math.Max(Math.Abs(xOffset), Math.Abs(zOffset)); + int insideWallDepth = 0; + + for (int step = 0; step < 2; step++) + { + int wx = x + lateralX + (forwardX * step); + int wz = z + lateralZ + (forwardZ * step); + if (ctx.CanWalkThrough(wx, y, wz) && ctx.CanWalkThrough(wx, y + 1, wz)) + break; + insideWallDepth++; + } + + if (insideWallDepth is < 1 or > 2) + return false; + + for (int step = 1; step <= major; step++) + { + int cx = x + (forwardX * step); + int cz = z + (forwardZ * step); + if (!IsColumnPassable(ctx, cx, y, cz)) + return false; + } + + int outsideX = x - lateralX; + int outsideZ = z - lateralZ; + return IsColumnPassable(ctx, outsideX, y, outsideZ); +} + +internal static bool HasSidewallLandingClearance(CalculationContext ctx, int destX, int destY, int destZ, int forwardX, int forwardZ, int lateralX, int lateralZ) +{ + if (!ctx.CanWalkOn(destX, destY - 1, destZ)) + return false; + + if (!IsColumnPassable(ctx, destX, destY, destZ)) + return false; + + if (!IsColumnPassable(ctx, destX + forwardX, destY, destZ + forwardZ)) + return false; + + if (!IsColumnPassable(ctx, destX - lateralX, destY, destZ - lateralZ)) + return false; + + return true; +} +``` + +```csharp +// MinecraftClient/Pathing/Core/AStarPathFinder.cs +foreach (int dx in offsets) +{ + foreach (int dz in offsets) + { + foreach (int distance in new[] { 2, 3, 4, 5 }) + { + moves.Add(new MoveSidewallParkour(dx, dz * distance)); + moves.Add(new MoveSidewallParkour(dx * distance, dz)); + + if (distance <= 3) + { + moves.Add(new MoveSidewallParkour(dx, dz * distance, yDelta: 1)); + moves.Add(new MoveSidewallParkour(dx * distance, dz, yDelta: 1)); + } + + moves.Add(new MoveSidewallParkour(dx, dz * distance, yDelta: -1)); + moves.Add(new MoveSidewallParkour(dx * distance, dz, yDelta: -1)); + moves.Add(new MoveSidewallParkour(dx, dz * distance, yDelta: -2)); + moves.Add(new MoveSidewallParkour(dx * distance, dz, yDelta: -2)); + } + } +} +``` + +- [ ] **Step 4: Run the planner tests to verify they pass** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~MoveSidewallParkourTests|FullyQualifiedName~AStar_Sidewall" -v minimal +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add MinecraftClient/Pathing/Moves/Impl/MoveSidewallParkour.cs \ + MinecraftClient/Pathing/Moves/ParkourFeasibility.cs \ + MinecraftClient/Pathing/Core/AStarPathFinder.cs \ + MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs \ + MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs +git commit -m "feat: add sidewall parkour planner support" +``` + +--- + +### Task 4: Teach The Executor To Take Sidewall Jumps Without Replan Or Spin + +**Files:** +- Modify: `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs` +- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs` + +- [ ] **Step 1: Write the failing execution tests** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs +[Fact] +public void SprintJumpTemplate_SidewallFlatGap2_FinalStop_CompletesInsideLandingBlock() +{ + World world = SidewallParkourScenarioBuilder.BuildWorld(gap: 2, deltaY: 0, wallOffset: 0); + var segment = new PathSegment + { + Start = new Location(100.5, 80, 100.5), + End = new Location(99.5, 80, 102.5), + MoveType = MoveType.Parkour, + ParkourProfile = ParkourProfile.Sidewall, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new SprintJumpTemplate(segment, null); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 0f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 120, out Location finalPos); + + Assert.Equal(TemplateState.Complete, state); + Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); +} +``` + +```csharp +// MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs +[Theory] +[MemberData(nameof(SidewallParkourScenarioBuilder.AcceptedCases), MemberType = typeof(SidewallParkourScenarioBuilder))] +public void Tick_SidewallAcceptedCases_CompletesWithoutReplan(string scenarioId, int gap, int deltaY, int wallOffset) +{ + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create(scenarioId, gap, deltaY, wallOffset); + PathingScenarioResult result = PathingScenarioRunner.RunAccepted(scenario); + + Assert.True( + result.Completed && result.ReplanCount == 0, + $"scenario={scenarioId} completed={result.Completed} replans={result.ReplanCount} final={result.FinalPosition}\n" + + $"{string.Join('\n', result.InfoLogs)}\n{string.Join('\n', result.DebugLogs)}"); +} +``` + +- [ ] **Step 2: Run the execution tests to verify they fail** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~SprintJumpTemplate_Sidewall|FullyQualifiedName~Tick_SidewallAcceptedCases" -v minimal +``` + +Expected: FAIL because the current template turns toward the landing yaw before it has built runway momentum, which either stalls in place or forces a rescue replan after a bad takeoff. + +- [ ] **Step 3: Use `ParkourProfile.Sidewall` to separate runway heading from landing heading** + +```csharp +// MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs +internal static void GetApproachHeading(PathSegment segment, out int headingX, out int headingZ) +{ + if (segment.ParkourProfile == ParkourProfile.Sidewall) + { + double dx = Math.Abs(segment.End.X - segment.Start.X); + double dz = Math.Abs(segment.End.Z - segment.Start.Z); + + if (dx > dz) + { + headingX = segment.HeadingX; + headingZ = 0; + } + else + { + headingX = 0; + headingZ = segment.HeadingZ; + } + + return; + } + + headingX = segment.HeadingX; + headingZ = segment.HeadingZ; +} + +internal static float GetApproachYaw(PathSegment segment) +{ + GetApproachHeading(segment, out int headingX, out int headingZ); + return CalculateYaw(headingX, headingZ); +} + +internal static double ProgressAlongApproach(Location start, Location pos, PathSegment segment) +{ + GetApproachHeading(segment, out int headingX, out int headingZ); + return ((pos.X - start.X) * headingX) + ((pos.Z - start.Z) * headingZ); +} +``` + +```csharp +// MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs +float approachYaw = TemplateHelper.GetApproachYaw(_segment); +float activeYaw = _phase == Phase.Approach ? approachYaw : targetYaw; + +physics.Yaw = groundedPrepareJumpHandoff + ? TemplateHelper.SmoothYaw(physics.Yaw, TemplateHelper.GetExitHeadingYaw(_segment)) + : TemplateHelper.SmoothYaw(physics.Yaw, activeYaw); + +case Phase.Approach: + if (physics.OnGround) + { + double approachProgress = TemplateHelper.ProgressAlongApproach(ExpectedStart, pos, _segment); + float yawDelta = YawDifference(physics.Yaw, approachYaw); + bool turnInPlace = yawDelta > 35f; + input.Forward = !turnInPlace; + input.Sprint = !turnInPlace; + + double minApproachDistance = _segment.ParkourProfile == ParkourProfile.Sidewall + ? 0.9 + : _horizDist >= 5.0 ? 0.8 + : _horizDist >= 4.0 ? 0.6 + : _horizDist > 3.5 ? 0.3 + : 0.0; + + if (yawDelta < YawToleranceDeg && approachProgress >= minApproachDistance) + { + input.Jump = true; + _phase = Phase.Airborne; + } + } + break; + +case Phase.Airborne: + if (_segment.ParkourProfile == ParkourProfile.Sidewall && _leftGround) + physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw, maxStep: 20f); + break; +``` + +- [ ] **Step 4: Run the execution tests to verify they pass** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~SprintJumpTemplate_Sidewall|FullyQualifiedName~Tick_SidewallAcceptedCases" -v minimal +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs \ + MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs \ + MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs \ + MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs +git commit -m "feat: execute sidewall parkour without replan" +``` + +--- + +### Task 5: Verify Sidewall Live Matrix And Protect Linear + +**Files:** +- Test: `MinecraftClient.Tests/Pathing/Execution/SidewallParkourScenarioBuilderTests.cs` +- Test: `MinecraftClient.Tests/Pathing/Execution/PathSegmentBuilderTests.cs` +- Test: `MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs` +- Test: `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs` +- Test: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` +- Test: `MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs` + +- [ ] **Step 1: Run the targeted .NET sidewall regression suite** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~SidewallParkourScenarioBuilderTests|FullyQualifiedName~FromPath_CopiesParkourProfile_ToRuntimeSegment|FullyQualifiedName~MoveSidewallParkourTests|FullyQualifiedName~AStar_Sidewall|FullyQualifiedName~SprintJumpTemplate_Sidewall|FullyQualifiedName~Tick_SidewallAcceptedCases" -v minimal +``` + +Expected: PASS. + +- [ ] **Step 2: Run the full sidewall live matrix in parallel** + +Run: + +```bash +source tools/mcc-env.sh && python3 tools/test-parkour.py --filter sidewall --parallel 6 --version 1.21.11-Vanilla --results /tmp/sidewall-parkour-final.jsonl +``` + +Expected: summary reports `30/30 matched expectations` and `0 cases skipped`. + +- [ ] **Step 3: Prove the sidewall JSONL has zero replan and zero turn-stall on accepted cases** + +Run: + +```bash +python3 - <<'PY' +import json +from pathlib import Path + +rows = [json.loads(line) for line in Path('/tmp/sidewall-parkour-final.jsonl').read_text().splitlines() if line.strip()] +assert len(rows) == 30, len(rows) +assert sum(1 for row in rows if row["matched"]) == 30 +assert all( + row["outcome"] != "pass" or (row["replan_count"] == 0 and row["turn_stall_count"] == 0) + for row in rows +) +print("rows", len(rows)) +print("matched", sum(1 for row in rows if row["matched"])) +print("pass_cases", sum(1 for row in rows if row["outcome"] == "pass")) +print("reject_cases", sum(1 for row in rows if row["outcome"] == "reject")) +PY +``` + +Expected: + +```text +rows 30 +matched 30 +pass_cases 22 +reject_cases 8 +``` + +- [ ] **Step 4: Re-run the linear live matrix as a hard regression guard** + +Run: + +```bash +source tools/mcc-env.sh && python3 tools/test-parkour.py --filter linear --parallel 6 --version 1.21.11-Vanilla --results /tmp/linear-guard-after-sidewall.jsonl +python3 - <<'PY' +import json +from pathlib import Path + +rows = [json.loads(line) for line in Path('/tmp/linear-guard-after-sidewall.jsonl').read_text().splitlines() if line.strip()] +assert len(rows) == 22, len(rows) +assert sum(1 for row in rows if row["matched"]) == 22 +assert all( + row["outcome"] != "pass" or (row["replan_count"] == 0 and row["turn_stall_count"] == 0) + for row in rows +) +print("rows", len(rows)) +print("matched", sum(1 for row in rows if row["matched"])) +print("pass_cases", sum(1 for row in rows if row["outcome"] == "pass")) +print("reject_cases", sum(1 for row in rows if row["outcome"] == "reject")) +PY +``` + +Expected: + +```text +rows 22 +matched 22 +pass_cases 18 +reject_cases 4 +``` + +- [ ] **Step 5: Re-run the existing green linear .NET regressions** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~Tick_Linear|FullyQualifiedName~AStar_Linear|FullyQualifiedName~PathSegmentManager_LiveCoordinateLinear" -v minimal +``` + +Expected: PASS. + +## Self-Review + +**1. Spec coverage** + +- Sidewall pass coverage: Task 3 adds exact theory-allowed and theory-forbidden planner tests; Task 4 adds executor no-replan tests; Task 5 runs the full `sidewall` live matrix. +- Zero replan / zero turn stall: Task 4 enforces `PathSegmentManager` no-replan behavior; Task 5 validates JSONL `replan_count` and `turn_stall_count`. +- Parallel verification through `tools/test-parkour.py`: Task 5 uses `--parallel 6`. +- Preserve linear: Task 5 re-runs both live `linear` matrix and existing green linear .NET regressions. +- Excluding `neo` and `ceiling`: called out explicitly in Scope And Guardrails. + +**2. Placeholder scan** + +- No `TODO`, `TBD`, or “similar to above” placeholders remain. +- Every task lists exact file paths, concrete test names, explicit commands, and concrete code identifiers. + +**3. Type consistency** + +- `ParkourProfile` is the single profile type threaded across `MoveResult`, `PathNode`, and `PathSegment`. +- `MoveSidewallParkour` is the dedicated planner type; generic `MoveParkour` remains tagged as `ParkourProfile.Default`. +- `SidewallParkourScenarioBuilder` is the shared fixture source used by move tests, live planner tests, and manager tests. diff --git a/docs/superpowers/plans/2026-04-19-sidewall-runup-precondition-plan.md b/docs/superpowers/plans/2026-04-19-sidewall-runup-precondition-plan.md new file mode 100644 index 00000000..efe977ad --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-sidewall-runup-precondition-plan.md @@ -0,0 +1,596 @@ +# Sidewall Runup Precondition Implementation Plan + +I'm using the writing-plans skill to create the 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 remaining static-entry sidewall long-descend jumps plan through an explicit backward-then-forward runway setup, while preserving the current all-green `linear` behavior and keeping accepted executions at `replan_count=0` and `turn_stall_count=0`. + +**Architecture:** Extend A* with a narrow `EntryPreparationState` keyed into the search node identity, so the pathfinder can distinguish “standing on the launch block unprepared” from “standing on the same block after completing a setup runway.” Keep the resulting path explicit by composing the setup out of ordinary `Traverse` segments, and gate `MoveSidewallParkour` on the prepared state only for the narrow profile that currently needs extra entry momentum. + +**Tech Stack:** .NET 10 / C# 14, xUnit, MCC pathing core, `tools/test-parkour.py`, local `1.21.11-Vanilla` live harness + +--- + +## Scope And Guardrails + +- Phase 1 activation is intentionally narrow: static-entry `sidewall`, `yDelta == -1`, dominant distance `== 5`, no carry-in. +- Do not edit `MoveParkour` or any generic linear admissibility logic in this plan. +- Do not touch `SprintJumpTemplate` or `SidewallParkourController` in the first pass. If the new explicit runway path exposes a runtime issue later, stop and write a follow-up plan instead of silently expanding scope. +- The workspace is already dirty. Do not reset, discard, or overwrite unrelated changes. Save new docs under `2026-04-19-*` filenames instead of modifying the existing untracked `2026-04-18` sidewall plan. +- The verification gate for this plan is targeted pathing tests plus the live harness, not the full `MinecraftClient.Tests` suite, because the baseline still contains unrelated failures. + +## File Structure + +- Create: `MinecraftClient/Pathing/Core/EntryPreparationKind.cs` + Responsibility: enum describing whether a node has no setup state or a sidewall runup state. +- Create: `MinecraftClient/Pathing/Core/EntryPreparationState.cs` + Responsibility: immutable value object that records launch origin, dominant axis, required steps, and progress through backward/return phases. +- Modify: `MinecraftClient/Pathing/Core/PathNode.cs` + Responsibility: store `EntryPreparationState` on each node. +- Modify: `MinecraftClient/Pathing/Core/CalculationContext.cs` + Responsibility: expose the current node’s `EntryPreparationState` to move feasibility. +- Modify: `MinecraftClient/Pathing/Core/AStarPathFinder.cs` + Responsibility: key nodes by position plus preparation state, seed and advance sidewall runup setup, and preserve explicit traverse segments in the planned path. +- Modify: `MinecraftClient/Pathing/Moves/ParkourFeasibility.cs` + Responsibility: classify when a sidewall profile requires setup and validate a prepared setup against launch origin and dominant axis. +- Modify: `MinecraftClient/Pathing/Moves/Impl/MoveSidewallParkour.cs` + Responsibility: reject the narrow long-descend static-entry sidewall profile unless the current node carries a matching prepared setup state. +- Modify: `MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs` + Responsibility: direct admissibility guard for long-descend sidewall static entry. +- Modify: `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs` + Responsibility: assert the planner emits explicit setup traverses for the long-descend sidewall profile and does not inject setup into linear routes. +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs` + Responsibility: ensure the planned sidewall setup path still executes at `0 replan`. +- Use for verification only: `tools/test-parkour.py` + Responsibility: live-harness acceptance, not production code changes. + +### Task 1: Freeze The Required Planner Shape In Tests + +**Files:** +- Modify: `MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs` +- Modify: `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs` + +- [ ] **Step 1: Add a direct move test that proves long-descend sidewall static entry is not directly admissible** + +```csharp +// MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs +[Theory] +[InlineData("sidewall-descend-gap5-dy-1-wo0", 5, 0)] +[InlineData("sidewall-descend-gap5-dy-1-wo1", 5, 1)] +public void Calculate_LongDescendStaticEntry_RejectsWithoutPreparedRunup( + string scenarioId, + int gap, + int wallOffset) +{ + World world = SidewallParkourScenarioBuilder.BuildWorld(gap, deltaY: -1, wallOffset); + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var move = new MoveSidewallParkour(xOffset: -1, zOffset: gap, yDelta: -1); + MoveResult result = default; + + move.Calculate(ctx, 100, 80, 100, ref result); + + Assert.True(result.IsImpossible, scenarioId); +} +``` + +- [ ] **Step 2: Add a planner regression that requires explicit setup traverses before the first sidewall jump** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs +[Theory] +[InlineData("sidewall-descend-gap5-dy-1-wo0", 5, 0)] +[InlineData("sidewall-descend-gap5-dy-1-wo1", 5, 1)] +public void AStar_SidewallLongDescendStaticEntry_PrependsExplicitRunupTraverses( + string scenarioId, + int gap, + int wallOffset) +{ + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create( + scenarioId, + gap, + deltaY: -1, + wallOffset); + PathResult result = PathingScenarioRunner.PlanOnly(scenario); + List segments = PathSegmentBuilder.FromPath(result.Path); + + Assert.Equal(PathStatus.Success, result.Status); + + int firstParkourIndex = segments.FindIndex(segment => segment.MoveType == MoveType.Parkour); + Assert.True(firstParkourIndex >= 4, string.Join('\n', segments)); + Assert.All( + segments.Take(firstParkourIndex), + segment => Assert.Equal(MoveType.Traverse, segment.MoveType)); + Assert.Equal(new Location(100.5, 80, 100.5), segments[firstParkourIndex - 1].End); + Assert.Equal(ParkourProfile.Sidewall, segments[firstParkourIndex].ParkourProfile); +} +``` + +- [ ] **Step 3: Add a linear regression that proves no setup is injected into an already-green route** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs +[Fact] +public void AStar_LinearFlatGap4_DoesNotInsertRunupSetupSegments() +{ + 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(MoveType.Parkour, segments[0].MoveType); +} +``` + +- [ ] **Step 4: Run the focused planner tests and verify they fail before implementation** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~MoveSidewallParkourTests|FullyQualifiedName~LivePathingRegressionTests" -v minimal +``` + +Expected: + +- `Calculate_LongDescendStaticEntry_RejectsWithoutPreparedRunup` fails because the move still admits the jump directly. +- `AStar_SidewallLongDescendStaticEntry_PrependsExplicitRunupTraverses` fails because the planner still tries to jump from a plain origin node. + +### Task 2: Add Search-State Types For Planner-Side Setup + +**Files:** +- Create: `MinecraftClient/Pathing/Core/EntryPreparationKind.cs` +- Create: `MinecraftClient/Pathing/Core/EntryPreparationState.cs` +- Modify: `MinecraftClient/Pathing/Core/PathNode.cs` +- Modify: `MinecraftClient/Pathing/Core/CalculationContext.cs` + +- [ ] **Step 1: Add the preparation-kind enum** + +```csharp +// MinecraftClient/Pathing/Core/EntryPreparationKind.cs +namespace MinecraftClient.Pathing.Core +{ + public enum EntryPreparationKind + { + None = 0, + SidewallRunup = 1 + } +} +``` + +- [ ] **Step 2: Add the immutable preparation-state value object** + +```csharp +// MinecraftClient/Pathing/Core/EntryPreparationState.cs +namespace MinecraftClient.Pathing.Core +{ + public readonly record struct EntryPreparationState( + EntryPreparationKind Kind, + int OriginX, + int OriginY, + int OriginZ, + int ForwardX, + int ForwardZ, + byte RequiredSteps, + byte BackwardSteps, + byte ReturnSteps) + { + public static EntryPreparationState None => default; + + public bool IsNone => Kind == EntryPreparationKind.None; + + public bool IsPrepared => + Kind != EntryPreparationKind.None && + BackwardSteps == RequiredSteps && + ReturnSteps == RequiredSteps; + + public EntryPreparationState AdvanceBackward() => + this with { BackwardSteps = (byte)(BackwardSteps + 1) }; + + public EntryPreparationState AdvanceReturn() => + this with { ReturnSteps = (byte)(ReturnSteps + 1) }; + } +} +``` + +- [ ] **Step 3: Thread the state through path nodes and calculation context** + +```csharp +// MinecraftClient/Pathing/Core/PathNode.cs +public EntryPreparationState EntryPreparation; + +// MinecraftClient/Pathing/Core/CalculationContext.cs +public EntryPreparationState CurrentEntryPreparation { get; internal set; } +``` + +- [ ] **Step 4: Run a compile-only build to catch signature issues early** + +Run: + +```bash +dotnet build MinecraftClient.sln -c Debug +``` + +Expected: + +- The build fails in `AStarPathFinder` and `MoveSidewallParkour` because the new state has not been wired into expansion logic yet. + +### Task 3: Key A* By Position Plus Preparation State And Advance The Setup Path + +**Files:** +- Modify: `MinecraftClient/Pathing/Core/AStarPathFinder.cs` + +- [ ] **Step 1: Add a search-key type inside `AStarPathFinder` and stop deduplicating by packed position alone** + +```csharp +// MinecraftClient/Pathing/Core/AStarPathFinder.cs +private readonly record struct NodeKey(long PackedPosition, EntryPreparationState EntryPreparation); + +// replace +var nodeMap = new Dictionary(4096); + +// replace start-node insertion +nodeMap[new NodeKey(startNode.PackedPosition, startNode.EntryPreparation)] = startNode; +``` + +- [ ] **Step 2: Push the current node state into the calculation context before each move expansion** + +```csharp +// MinecraftClient/Pathing/Core/AStarPathFinder.cs +foreach (var move in _allMoves) +{ + ctx.PreviousMoveType = current.MoveUsed; + ctx.CurrentEntryPreparation = current.EntryPreparation; + moveResult.Cost = 0; + move.Calculate(ctx, current.X, current.Y, current.Z, ref moveResult); + // ... +} +``` + +- [ ] **Step 3: Add a helper that seeds, advances, or clears the setup state using only ordinary `Traverse` moves** + +```csharp +// MinecraftClient/Pathing/Core/AStarPathFinder.cs +private EntryPreparationState ResolveEntryPreparation(PathNode current, IMove move, in MoveResult moveResult) +{ + EntryPreparationState advanced = AdvanceExistingPreparation(current, move, moveResult); + if (!advanced.IsNone) + return advanced; + + if (TryStartSidewallRunupPreparation(current, move, moveResult, out EntryPreparationState started)) + return started; + + return EntryPreparationState.None; +} + +private static EntryPreparationState AdvanceExistingPreparation(PathNode current, IMove move, in MoveResult moveResult) +{ + EntryPreparationState state = current.EntryPreparation; + if (state.IsNone) + return EntryPreparationState.None; + + if (move.Type != MoveType.Traverse || moveResult.DestY != current.Y) + return EntryPreparationState.None; + + int stepX = moveResult.DestX - current.X; + int stepZ = moveResult.DestZ - current.Z; + + if (state.BackwardSteps < state.RequiredSteps && + stepX == -state.ForwardX && + stepZ == -state.ForwardZ) + { + return state.AdvanceBackward(); + } + + if (state.BackwardSteps == state.RequiredSteps && + state.ReturnSteps < state.RequiredSteps && + stepX == state.ForwardX && + stepZ == state.ForwardZ) + { + return state.AdvanceReturn(); + } + + return EntryPreparationState.None; +} +``` + +- [ ] **Step 4: Seed the setup state only when a one-block backward traverse matches a setup-required sidewall profile from the current origin** + +```csharp +// MinecraftClient/Pathing/Core/AStarPathFinder.cs +private bool TryStartSidewallRunupPreparation( + PathNode current, + IMove move, + in MoveResult moveResult, + out EntryPreparationState state) +{ + state = EntryPreparationState.None; + + if (current.EntryPreparation.Kind != EntryPreparationKind.None || + move.Type != MoveType.Traverse || + moveResult.DestY != current.Y) + { + return false; + } + + int stepX = moveResult.DestX - current.X; + int stepZ = moveResult.DestZ - current.Z; + + foreach (MoveSidewallParkour sidewallMove in _allMoves.OfType()) + { + if (!ParkourFeasibility.TryGetRequiredStaticEntryRunupSteps( + current.MoveUsed, + sidewallMove.XOffset, + sidewallMove.ZOffset, + sidewallMove.YDelta, + out int requiredSteps)) + { + continue; + } + + ParkourFeasibility.GetSidewallAxes( + sidewallMove.XOffset, + sidewallMove.ZOffset, + out int forwardX, + out int forwardZ, + out _, + out _); + + if (stepX == -forwardX && stepZ == -forwardZ) + { + state = new EntryPreparationState( + EntryPreparationKind.SidewallRunup, + current.X, + current.Y, + current.Z, + forwardX, + forwardZ, + (byte)requiredSteps, + BackwardSteps: 1, + ReturnSteps: 0); + return true; + } + } + + return false; +} +``` + +- [ ] **Step 5: Attach the resolved state to the neighbor before node lookup and update the node-map key** + +```csharp +// MinecraftClient/Pathing/Core/AStarPathFinder.cs +EntryPreparationState nextPreparation = ResolveEntryPreparation(current, move, moveResult); +var key = new NodeKey(PathNode.Pack(nx, ny, nz), nextPreparation); + +if (nodeMap.TryGetValue(key, out var neighbor)) +{ + // existing better-path update + neighbor.EntryPreparation = nextPreparation; +} +else +{ + neighbor = new PathNode(nx, ny, nz) + { + GCost = tentativeG, + HCost = goal.Heuristic(nx, ny, nz), + Parent = current, + MoveUsed = move.Type, + ParkourProfile = moveResult.ParkourProfile, + EntryPreparation = nextPreparation, + IsOpen = true + }; + nodeMap[key] = neighbor; + openSet.Insert(neighbor); +} +``` + +- [ ] **Step 6: Run the focused planner tests again** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~MoveSidewallParkourTests|FullyQualifiedName~LivePathingRegressionTests" -v minimal +``` + +Expected: + +- The compile-time errors are gone. +- The planner-shape test still fails until `MoveSidewallParkour` recognizes the prepared state. + +### Task 4: Gate The Narrow Sidewall Profile On Prepared Setup + +**Files:** +- Modify: `MinecraftClient/Pathing/Moves/ParkourFeasibility.cs` +- Modify: `MinecraftClient/Pathing/Moves/Impl/MoveSidewallParkour.cs` + +- [ ] **Step 1: Add a helper that classifies setup-required sidewall profiles** + +```csharp +// MinecraftClient/Pathing/Moves/ParkourFeasibility.cs +public static bool TryGetRequiredStaticEntryRunupSteps( + MoveType previousMoveType, + int xOffset, + int zOffset, + int yDelta, + out int requiredSteps) +{ + requiredSteps = 0; + + if (previousMoveType is MoveType.Parkour or MoveType.Descend) + return false; + + int major = Math.Max(Math.Abs(xOffset), Math.Abs(zOffset)); + if (yDelta == -1 && major == 5) + { + requiredSteps = 2; + return true; + } + + return false; +} +``` + +- [ ] **Step 2: Add a helper that validates a prepared setup against the exact launch origin and dominant axis** + +```csharp +// MinecraftClient/Pathing/Moves/ParkourFeasibility.cs +public static bool HasPreparedRunup( + EntryPreparationState state, + int x, + int y, + int z, + int forwardX, + int forwardZ, + int requiredSteps) +{ + return state.Kind == EntryPreparationKind.SidewallRunup && + state.IsPrepared && + state.OriginX == x && + state.OriginY == y && + state.OriginZ == z && + state.ForwardX == forwardX && + state.ForwardZ == forwardZ && + state.RequiredSteps == requiredSteps; +} +``` + +- [ ] **Step 3: Expose `YDelta` from `MoveSidewallParkour` so the pathfinder can inspect sidewall candidates** + +```csharp +// MinecraftClient/Pathing/Moves/Impl/MoveSidewallParkour.cs +public int YDelta => _yDelta; +``` + +- [ ] **Step 4: Reject the narrow static-entry profile unless the current node carries a matching prepared setup** + +```csharp +// MinecraftClient/Pathing/Moves/Impl/MoveSidewallParkour.cs +ParkourFeasibility.GetSidewallAxes(XOffset, ZOffset, out int forwardX, out int forwardZ, out int lateralX, out int lateralZ); + +if (ParkourFeasibility.TryGetRequiredStaticEntryRunupSteps( + ctx.PreviousMoveType, + XOffset, + ZOffset, + _yDelta, + out int requiredSteps)) +{ + if (!ParkourFeasibility.HasPreparedRunup( + ctx.CurrentEntryPreparation, + x, + y, + z, + forwardX, + forwardZ, + requiredSteps)) + { + result.SetImpossible(); + return; + } +} +else if (!ParkourFeasibility.HasDominantAxisRunUp(ctx, x, y, z, forwardX, forwardZ, XOffset, ZOffset, _yDelta)) +{ + result.SetImpossible(); + return; +} +``` + +- [ ] **Step 5: Run the focused planner tests and verify they now pass** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~MoveSidewallParkourTests|FullyQualifiedName~LivePathingRegressionTests" -v minimal +``` + +Expected: + +- `Calculate_LongDescendStaticEntry_RejectsWithoutPreparedRunup` passes. +- `AStar_SidewallLongDescendStaticEntry_PrependsExplicitRunupTraverses` passes. +- `AStar_LinearFlatGap4_DoesNotInsertRunupSetupSegments` passes. + +### Task 5: Prove The New Path Still Executes At Zero Replan + +**Files:** +- Modify: `MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs` + +- [ ] **Step 1: Add a focused execution regression for the two long-descend sidewall profiles** + +```csharp +// MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs +[Theory] +[InlineData("sidewall-descend-gap5-dy-1-wo0", 5, 0)] +[InlineData("sidewall-descend-gap5-dy-1-wo1", 5, 1)] +public void Tick_SidewallLongDescendRunupSetup_CompletesWithoutReplan( + string scenarioId, + int gap, + int wallOffset) +{ + PathingExecutionScenario scenario = SidewallParkourScenarioBuilder.Create( + scenarioId, + gap, + deltaY: -1, + wallOffset); + 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}\n" + + $"info={string.Join('\n', result.InfoLogs)}\ndebug={string.Join('\n', result.DebugLogs)}"); +} +``` + +- [ ] **Step 2: Run the targeted execution tests** + +Run: + +```bash +dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~PathSegmentManagerTests|FullyQualifiedName~Linear" -v minimal +``` + +Expected: + +- The two new sidewall long-descend execution tests pass at `ReplanCount == 0`. +- Existing linear execution guards remain green. + +### Task 6: Run Live Harness Verification + +**Files:** No production-file changes in this task. + +- [ ] **Step 1: Run the split live sidewall and linear matrices** + +Run: + +```bash +python3 tools/test-parkour.py --parallel 6 --version 1.21.11-Vanilla --filter sidewall/descend +python3 tools/test-parkour.py --parallel 6 --version 1.21.11-Vanilla --filter sidewall/flat +python3 tools/test-parkour.py --parallel 6 --version 1.21.11-Vanilla --filter sidewall/ascend +python3 tools/test-parkour.py --parallel 6 --version 1.21.11-Vanilla --filter linear +``` + +Expected: + +- `sidewall-descend-gap5-dy-1-wo0` and `sidewall-descend-gap5-dy-1-wo1` move from mismatch to match. +- No accepted linear case regresses. +- Accepted runs show `replan_count=0` and no turn-stall trace. + +- [ ] **Step 2: Run the full matrix once after the split runs are green** + +Run: + +```bash +python3 tools/test-parkour.py --parallel 6 --version 1.21.11-Vanilla +``` + +Expected: + +- The matrix summary reflects sidewall alignment without creating new linear mismatches. + +## Self-Review Checklist + +- The implementation intentionally does not modify `SprintJumpTemplate`, `SidewallParkourController`, or generic `MoveParkour` logic. +- Every new planner state transition is driven by an explicit same-level `Traverse`, so the resulting path stays visible and inspectable. +- The node-map key change is the only search-core behavior broad enough to affect unrelated routes; that is why the linear planner and execution guards are part of the mandatory validation set. diff --git a/docs/superpowers/specs/2026-04-19-sidewall-runup-precondition-design.md b/docs/superpowers/specs/2026-04-19-sidewall-runup-precondition-design.md new file mode 100644 index 00000000..5728e1ff --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-sidewall-runup-precondition-design.md @@ -0,0 +1,175 @@ +# Sidewall Runup Precondition Planning + +## Context + +`sidewall` parkour still has a static-entry gap in the planner: some first-jump sidewall profiles are only physically reliable when the player backs up first, rebuilds sprint momentum along the dominant axis, returns to the launch origin, and then jumps. The current pathfinder cannot represent that distinction because it keys search nodes by position only and only exposes `PreviousMoveType` to move feasibility. + +The user explicitly rejected solving this with a runtime-only template hack. The required behavior is planner-driven: if a sidewall jump has an entry-momentum precondition, the produced path should contain explicit ground segments for the setup action, and accepted executions must still complete at `replan_count=0` and `turn_stall_count=0`. + +## Requirements + +- Keep the current `linear` matrix green. Do not loosen generic linear parkour rules to make sidewall pass. +- Make the setup action planner-visible. The final path must contain explicit ground segments before the first jump instead of hiding the behavior inside `SprintJumpTemplate` or `SidewallParkourController`. +- Keep scope narrow in phase 1. Only static-entry first-jump sidewall profiles may use the new setup mechanism. +- Do not hardcode case ids. Activation must be geometry/profile driven. +- Preserve the current carry-in path semantics. If a sidewall jump is already entered with valid carry, the planner must not inject a setup loop. +- In the current no-entity-collision environment, accepted sidewall cases must still run with `replan_count=0` and `turn_stall_count=0`. + +## Non-Goals + +- No generic “find any staging area behind me” feature in phase 1. +- No new runtime-only sidewall template family. +- No expansion to `neo`, `ceiling`, or generic `Parkour` in this change. +- No attempt to fix the unrelated `.NET` baseline failures outside the targeted sidewall/linear guard surface. + +## Design + +### Scope and activation + +Phase 1 adds a narrow planner-side precondition for static-entry sidewall jumps that need extra runway. Based on the latest verified matrix, the first activation predicate should be: + +- `ParkourProfile.Sidewall` +- no carry-in (`PreviousMoveType` is not `Parkour` or `Descend`) +- descending sidewall (`yDelta == -1`) +- dominant horizontal distance `== 5` + +This is intentionally narrow because the current verified live mismatch set is concentrated there and `linear` is already fully green. The predicate is profile-based, not case-id based, so it still follows geometry rather than scenario names. + +### Search-state extension + +The planner needs an extra discrete state to distinguish: + +- standing at the launch origin with no setup +- moving backward to build setup runway +- moving forward back toward the launch origin +- standing at the launch origin after completing the required setup + +Add two new core types under `MinecraftClient/Pathing/Core/`: + +- `EntryPreparationKind` +- `EntryPreparationState` + +`EntryPreparationState` should carry: + +- preparation kind, phase, and whether the state is empty +- launch origin (`OriginX`, `OriginY`, `OriginZ`) +- dominant forward axis (`ForwardX`, `ForwardZ`) +- required setup length in blocks +- completed backward steps +- completed return steps + +`PathNode` gets an `EntryPreparation` field. `CalculationContext` gets `CurrentEntryPreparation` so move feasibility can inspect the current node’s preparation state during expansion. `AStarPathFinder` must stop keying nodes by packed position only and instead key by `position + entry preparation state`. + +This is the critical design point: explicit path segments alone are not enough. Without a search-state distinction, A* would return to the same origin block and still evaluate the jump as a plain zero-entry sidewall jump. + +### How setup appears in the path + +The setup action should not introduce a new runtime `MoveType`. The visible path should be composed of ordinary ground moves: + +1. one or more `Traverse` segments backward along the dominant axis +2. the same number of `Traverse` segments forward along the dominant axis +3. the `Parkour` segment from the original launch block + +That keeps the execution layer simple. `PathSegmentBuilder` already marks a ground segment whose next segment is `Parkour` as `PrepareJump`, so the last forward traverse segment will naturally receive jump-ready transition hints without adding a new template concept. + +### Starting, advancing, and clearing setup state + +The setup state is seeded and advanced in the pathfinder, not inside the movement templates. + +#### Starting setup + +When A* is expanding a node with empty `EntryPreparationState`, and it considers a one-block `Traverse` that moves exactly opposite the dominant axis of a sidewall jump that requires setup from the current origin, the neighbor should receive a seeded `EntryPreparationState`: + +- origin set to the current block +- forward axis set to the jump’s dominant direction +- required steps set from the profile helper +- backward steps initialized to `1` +- return steps initialized to `0` + +This keeps the setup path explicit because the first action is still an actual ground move. + +#### Advancing setup + +While the node is in a setup state: + +- a same-level `Traverse` exactly opposite the forward axis increments backward progress until the required count is reached +- after backward progress is full, a same-level `Traverse` exactly along the forward axis increments return progress +- when return progress reaches the required count, the destination must be the original launch origin and the state becomes “prepared” + +#### Clearing setup + +Any other move clears the setup state immediately: + +- `Diagonal`, `Ascend`, `Descend`, `Fall`, `Climb`, `Parkour` +- same-level `Traverse` in the wrong direction +- any move that changes Y +- any return that overshoots the launch origin + +This keeps the mechanic narrow and predictable. The phase-1 behavior is “straight backward, then straight forward, then jump,” not a general-purpose staged maneuver planner. + +### Sidewall admissibility split + +`ParkourFeasibility` should stop treating this as a simple yes/no runway check. It needs to distinguish: + +- physically impossible profile +- directly admissible profile +- profile admissible only after explicit setup + +Add a helper such as `TryGetRequiredStaticEntryRunupSteps(...)` that returns `0` for direct-entry sidewall jumps and a positive step count for setup-required profiles. In phase 1, this returns `2` for the narrow long-descend sidewall predicate above and `0` otherwise. + +`MoveSidewallParkour` then uses the split as follows: + +- if the profile does not require setup, keep the current dominant-axis admissibility logic +- if the profile requires setup, reject unless `CurrentEntryPreparation` is a prepared sidewall setup for the same launch origin, same forward axis, and same required length +- if carry-in is present, bypass setup entirely and keep the current carry behavior + +### Execution impact + +Execution changes should be avoided in phase 1. The produced path now contains explicit ground runway segments before the first sidewall jump, which means the existing transition logic should already hand the last forward traverse segment a `PrepareJump` exit transition. + +Do not modify `SprintJumpTemplate` or `SidewallParkourController` unless targeted tests prove the new path shape causes a fresh runtime regression. The primary fix is planner-side. + +## Testing Strategy + +### Targeted .NET regressions + +Add or update tests in: + +- `MinecraftClient.Tests/Pathing/Moves/MoveSidewallParkourTests.cs` +- `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs` +- `MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs` + +The key assertions are: + +- static-entry long-descend sidewall rejects without prepared setup +- A* for that profile succeeds and prepends explicit `Traverse` setup segments before the first `Parkour` +- the last setup traverse ends back on the original launch block +- the same sidewall chain still executes with `ReplanCount == 0` +- the linear planner path shape remains unchanged, with no inserted setup segments + +### Live harness + +Use `tools/test-parkour.py` in split runs to avoid stop-at-first-failure masking: + +- `--filter sidewall/descend` +- `--filter sidewall/flat` +- `--filter sidewall/ascend` +- `--filter linear` + +Then run the full matrix once after the targeted runs pass. + +## Risks and mitigations + +- Search-space growth: mitigated by activating setup only for one narrow sidewall profile and by clearing the preparation state on any non-axis-aligned move. +- Linear regression: mitigated by not touching `MoveParkour` or generic linear admissibility in phase 1. +- Runtime drift despite planner fix: mitigated by keeping the new path shape limited to ordinary `Traverse` plus existing `Parkour`, then proving `0 replan` and `0 turn stall` with targeted tests and live harness evidence. + +## Validation + +- `dotnet test MinecraftClient.Tests --filter "FullyQualifiedName~MoveSidewallParkourTests|FullyQualifiedName~LivePathingRegressionTests|FullyQualifiedName~PathSegmentManagerTests"` +- `dotnet test MinecraftClient.Tests --filter "FullyQualifiedName~Linear"` +- `python3 tools/test-parkour.py --parallel 6 --version 1.21.11-Vanilla --filter sidewall/descend` +- `python3 tools/test-parkour.py --parallel 6 --version 1.21.11-Vanilla --filter sidewall/flat` +- `python3 tools/test-parkour.py --parallel 6 --version 1.21.11-Vanilla --filter sidewall/ascend` +- `python3 tools/test-parkour.py --parallel 6 --version 1.21.11-Vanilla --filter linear` +- `python3 tools/test-parkour.py --parallel 6 --version 1.21.11-Vanilla`