mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
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
This commit is contained in:
parent
95b20d9d1c
commit
da52aa5c3c
15 changed files with 2925 additions and 25 deletions
|
|
@ -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<PathSegment> 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<PathSegment> 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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PathSegment> 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<PathSegment> 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<PathSegment> 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<PathSegment> 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<PathSegment> 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<PathSegment> 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<PathSegment> 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<PathSegment> 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<PathSegment> 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<PathSegment> 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<PathSegment> 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<PathSegment> 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<PathSegment> 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<PathSegment> 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<PathSegment> 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<PathSegment> 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<PathSegment> Segments, PlayerPhysics Physics) BuildPlannedLinearScenario(PathingExecutionScenario scenario)
|
||||
{
|
||||
return BuildPlannedScenario(scenario);
|
||||
}
|
||||
|
||||
private static (World World, List<PathSegment> 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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
8
MinecraftClient/Pathing/Core/EntryPreparationKind.cs
Normal file
8
MinecraftClient/Pathing/Core/EntryPreparationKind.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
namespace MinecraftClient.Pathing.Core
|
||||
{
|
||||
public enum EntryPreparationKind
|
||||
{
|
||||
None = 0,
|
||||
SidewallRunup = 1
|
||||
}
|
||||
}
|
||||
29
MinecraftClient/Pathing/Core/EntryPreparationState.cs
Normal file
29
MinecraftClient/Pathing/Core/EntryPreparationState.cs
Normal file
|
|
@ -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) };
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
1035
docs/superpowers/plans/2026-04-18-sidewall-parkour-zero-replan.md
Normal file
1035
docs/superpowers/plans/2026-04-18-sidewall-parkour-zero-replan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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<PathSegment> 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<PathSegment> 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<NodeKey, PathNode>(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<MoveSidewallParkour>())
|
||||
{
|
||||
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.
|
||||
|
|
@ -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`
|
||||
Loading…
Add table
Add a link
Reference in a new issue