mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
pathing: tighten linear parkour execution
This commit is contained in:
parent
891763602a
commit
d919bff91f
22 changed files with 2072 additions and 79 deletions
|
|
@ -10,6 +10,30 @@ namespace MinecraftClient.Tests.Pathing.Execution;
|
|||
|
||||
public sealed class GroundedTemplateConvergenceTests
|
||||
{
|
||||
[Fact]
|
||||
public void PlayerPhysics_SprintingGroundTravel_IsFasterThanWalking()
|
||||
{
|
||||
World world = FlatWorldTestBuilder.CreateStoneFloor(min: -2, max: 16);
|
||||
var walking = TemplateSimulationRunner.CreateGroundedPhysics(new Location(0.5, 80, 0.5), yaw: 270f);
|
||||
var sprinting = TemplateSimulationRunner.CreateGroundedPhysics(new Location(0.5, 80, 0.5), yaw: 270f);
|
||||
|
||||
for (int tick = 0; tick < 8; tick++)
|
||||
{
|
||||
walking.ApplyInput(new MovementInput { Forward = true });
|
||||
walking.Tick(world);
|
||||
|
||||
sprinting.ApplyInput(new MovementInput { Forward = true, Sprint = true });
|
||||
sprinting.Tick(world);
|
||||
}
|
||||
|
||||
double walkingTravel = walking.Position.X - 0.5;
|
||||
double sprintingTravel = sprinting.Position.X - 0.5;
|
||||
|
||||
Assert.True(
|
||||
sprintingTravel > walkingTravel + 0.05,
|
||||
$"walkingTravel={walkingTravel:F4} sprintingTravel={sprintingTravel:F4} walkVel={walking.DeltaMovement} sprintVel={sprinting.DeltaMovement}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WalkTemplate_FinalStop_Completes_WhenFootprintStaysInsideTargetBlock()
|
||||
{
|
||||
|
|
@ -541,6 +565,151 @@ public sealed class GroundedTemplateConvergenceTests
|
|||
Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescendTemplate_LandingRecovery_ChainCarry_CompletesBeforeSettling()
|
||||
{
|
||||
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 360, max: 369);
|
||||
FlatWorldTestBuilder.ClearBox(world, 360, 79, 358, 369, 85, 362);
|
||||
FlatWorldTestBuilder.FillSolid(world, 362, 79, 360, 363, 79, 360);
|
||||
FlatWorldTestBuilder.SetSolid(world, 364, 77, 360);
|
||||
FlatWorldTestBuilder.SetSolid(world, 365, 75, 360);
|
||||
FlatWorldTestBuilder.SetSolid(world, 366, 73, 360);
|
||||
|
||||
var segment = new PathSegment
|
||||
{
|
||||
Start = new Location(363.5, 80, 360.5),
|
||||
End = new Location(364.5, 78, 360.5),
|
||||
MoveType = MoveType.Descend,
|
||||
ExitTransition = PathTransitionType.LandingRecovery,
|
||||
ExitHints = new PathTransitionHints(1, 0, 0.03, double.PositiveInfinity, false, true, false, true, 12)
|
||||
};
|
||||
var next = new PathSegment
|
||||
{
|
||||
Start = new Location(364.5, 78, 360.5),
|
||||
End = new Location(365.5, 76, 360.5),
|
||||
MoveType = MoveType.Descend,
|
||||
ExitTransition = PathTransitionType.LandingRecovery,
|
||||
ExitHints = new PathTransitionHints(1, 0, 0.03, double.PositiveInfinity, false, true, false, true, 12)
|
||||
};
|
||||
|
||||
var template = new DescendTemplate(segment, next);
|
||||
var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f);
|
||||
var input = new MovementInput();
|
||||
var trace = new List<string>();
|
||||
TemplateState state = TemplateState.InProgress;
|
||||
Location finalPos = segment.Start;
|
||||
|
||||
for (int tick = 0; tick < 160; tick++)
|
||||
{
|
||||
input.Reset();
|
||||
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
state = template.Tick(pos, physics, input, world);
|
||||
trace.Add(
|
||||
$"tick={tick} state={state} pos={pos} vel={physics.DeltaMovement} onGround={physics.OnGround} " +
|
||||
$"input(F={input.Forward},B={input.Back},S={input.Sprint})");
|
||||
|
||||
if (state != TemplateState.InProgress)
|
||||
{
|
||||
finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
break;
|
||||
}
|
||||
|
||||
physics.ApplyInput(input);
|
||||
physics.Tick(world);
|
||||
finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
}
|
||||
|
||||
Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}");
|
||||
Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End), $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}");
|
||||
Assert.True(physics.DeltaMovement.X >= 0.03, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescendTemplate_LandingRecovery_ShortChain_DoesNotSwingYawAfterPassingEndPlane()
|
||||
{
|
||||
static bool HasReachedSegmentEndPlane(Location pos, PathSegment segment, double tolerance = 0.05)
|
||||
{
|
||||
double dirX = Math.Sign(segment.End.X - segment.Start.X);
|
||||
double dirZ = Math.Sign(segment.End.Z - segment.Start.Z);
|
||||
double relX = pos.X - segment.End.X;
|
||||
double relZ = pos.Z - segment.End.Z;
|
||||
return relX * dirX + relZ * dirZ >= -tolerance;
|
||||
}
|
||||
|
||||
static double HeadingPenaltyDegrees(float yaw, PathSegment segment)
|
||||
{
|
||||
float targetYaw = (float)(-Math.Atan2(segment.HeadingX, segment.HeadingZ) / Math.PI * 180.0);
|
||||
if (targetYaw < 0f)
|
||||
targetYaw += 360f;
|
||||
|
||||
float delta = targetYaw - yaw;
|
||||
while (delta > 180f) delta -= 360f;
|
||||
while (delta < -180f) delta += 360f;
|
||||
return Math.Abs(delta);
|
||||
}
|
||||
|
||||
World world = FlatWorldTestBuilder.CreateStoneFloor(floorY: 0, min: -2, max: 10);
|
||||
FlatWorldTestBuilder.ClearBox(world, -2, 1, -2, 10, 90, 2);
|
||||
FlatWorldTestBuilder.FillSolid(world, 0, 79, 0, 3, 79, 0);
|
||||
FlatWorldTestBuilder.SetSolid(world, 4, 77, 0);
|
||||
FlatWorldTestBuilder.SetSolid(world, 5, 75, 0);
|
||||
FlatWorldTestBuilder.SetSolid(world, 6, 73, 0);
|
||||
|
||||
var segment = new PathSegment
|
||||
{
|
||||
Start = new Location(4.5, 78, 0.5),
|
||||
End = new Location(5.5, 76, 0.5),
|
||||
MoveType = MoveType.Descend,
|
||||
ExitTransition = PathTransitionType.LandingRecovery,
|
||||
ExitHints = new PathTransitionHints(1, 0, 0.03, double.PositiveInfinity, false, true, false, true, 12)
|
||||
};
|
||||
var next = new PathSegment
|
||||
{
|
||||
Start = new Location(5.5, 76, 0.5),
|
||||
End = new Location(6.5, 74, 0.5),
|
||||
MoveType = MoveType.Descend,
|
||||
ExitTransition = PathTransitionType.LandingRecovery,
|
||||
ExitHints = new PathTransitionHints(1, 0, 0.03, double.PositiveInfinity, false, true, false, true, 12)
|
||||
};
|
||||
|
||||
var template = new DescendTemplate(segment, next);
|
||||
var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f);
|
||||
var input = new MovementInput();
|
||||
var trace = new List<string>();
|
||||
double maxHeadingPenaltyPastEnd = 0.0;
|
||||
TemplateState state = TemplateState.InProgress;
|
||||
Location finalPos = segment.Start;
|
||||
|
||||
for (int tick = 0; tick < 120; tick++)
|
||||
{
|
||||
input.Reset();
|
||||
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
state = template.Tick(pos, physics, input, world);
|
||||
bool pastEnd = HasReachedSegmentEndPlane(pos, segment);
|
||||
if (!physics.OnGround && pastEnd)
|
||||
maxHeadingPenaltyPastEnd = Math.Max(maxHeadingPenaltyPastEnd, HeadingPenaltyDegrees(physics.Yaw, segment));
|
||||
|
||||
trace.Add(
|
||||
$"tick={tick} state={state} pos={pos} yaw={physics.Yaw:F1} vel={physics.DeltaMovement} " +
|
||||
$"onGround={physics.OnGround} pastEnd={pastEnd} input(F={input.Forward},B={input.Back},S={input.Sprint})");
|
||||
|
||||
if (state != TemplateState.InProgress)
|
||||
{
|
||||
finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
break;
|
||||
}
|
||||
|
||||
physics.ApplyInput(input);
|
||||
physics.Tick(world);
|
||||
finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
}
|
||||
|
||||
Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}");
|
||||
Assert.True(
|
||||
maxHeadingPenaltyPastEnd <= 35.0,
|
||||
$"maxHeadingPenaltyPastEnd={maxHeadingPenaltyPastEnd:F1} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DescendTemplate_FinalStop_WithWallAndMisalignedYaw_CompletesOnLandingBlock()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using MinecraftClient.Mapping;
|
||||
using MinecraftClient.Pathing.Core;
|
||||
using MinecraftClient.Pathing.Execution;
|
||||
using MinecraftClient.Pathing.Execution.Templates;
|
||||
using MinecraftClient.Pathing.Goals;
|
||||
using MinecraftClient.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace MinecraftClient.Tests.Pathing.Execution;
|
||||
|
|
@ -115,4 +117,358 @@ public sealed class LivePathingRegressionTests
|
|||
double horizontalSpeed = Math.Sqrt(physics.DeltaMovement.X * physics.DeltaMovement.X + physics.DeltaMovement.Z * physics.DeltaMovement.Z);
|
||||
Assert.InRange(horizontalSpeed, 0.0, 0.04);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AStar_LinearFlatGapFourChain_PlansThroughAllThreeJumps()
|
||||
{
|
||||
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create("linear-flat-gap4", gap: 4, deltaY: 0);
|
||||
PathResult result = PathingScenarioRunner.PlanOnly(scenario);
|
||||
List<PathSegment> segments = PathSegmentBuilder.FromPath(result.Path);
|
||||
|
||||
Assert.Equal(PathStatus.Success, result.Status);
|
||||
Assert.NotEmpty(segments);
|
||||
Assert.Equal(scenario.Goal.X + 0.5, segments[^1].End.X);
|
||||
Assert.Equal(scenario.Goal.Y, segments[^1].End.Y);
|
||||
Assert.Equal(scenario.Goal.Z + 0.5, segments[^1].End.Z);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("linear-ascend-gap2-dy+1", 2, 1)]
|
||||
[InlineData("linear-descend-gap4-dy-1", 4, -1)]
|
||||
public void AStar_LinearChainCases_PlansThroughAllThreeJumps(string scenarioId, int gap, int deltaY)
|
||||
{
|
||||
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create(scenarioId, gap, deltaY);
|
||||
PathResult result = PathingScenarioRunner.PlanOnly(scenario);
|
||||
List<PathSegment> segments = PathSegmentBuilder.FromPath(result.Path);
|
||||
|
||||
Assert.Equal(PathStatus.Success, result.Status);
|
||||
Assert.NotEmpty(segments);
|
||||
Assert.Equal(scenario.Goal.X + 0.5, segments[^1].End.X);
|
||||
Assert.Equal(scenario.Goal.Y, segments[^1].End.Y);
|
||||
Assert.Equal(scenario.Goal.Z + 0.5, segments[^1].End.Z);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AStar_LinearDescendGap2DyMinus1_DoesNotSkipIntermediateLanding()
|
||||
{
|
||||
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create("linear-descend-gap2-dy-1", gap: 2, deltaY: -1);
|
||||
PathResult result = PathingScenarioRunner.PlanOnly(scenario);
|
||||
List<PathSegment> segments = PathSegmentBuilder.FromPath(result.Path);
|
||||
|
||||
Assert.Equal(PathStatus.Success, result.Status);
|
||||
Assert.DoesNotContain(
|
||||
segments,
|
||||
segment => segment.MoveType == MoveType.Parkour
|
||||
&& (Math.Abs(segment.End.X - segment.Start.X) + Math.Abs(segment.End.Z - segment.Start.Z)) > 3.1);
|
||||
Assert.Equal(scenario.Goal.X + 0.5, segments[^1].End.X);
|
||||
Assert.Equal(scenario.Goal.Y, segments[^1].End.Y);
|
||||
Assert.Equal(scenario.Goal.Z + 0.5, segments[^1].End.Z);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("linear-descend-gap4-dy-2", 4, -2)]
|
||||
public void AStar_LinearExtendedChainCases_PlansThroughAllThreeJumps(string scenarioId, int gap, int deltaY)
|
||||
{
|
||||
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create(scenarioId, gap, deltaY);
|
||||
PathResult result = PathingScenarioRunner.PlanOnly(scenario);
|
||||
List<PathSegment> segments = PathSegmentBuilder.FromPath(result.Path);
|
||||
|
||||
Assert.Equal(PathStatus.Success, result.Status);
|
||||
Assert.NotEmpty(segments);
|
||||
Assert.Equal(scenario.Goal.X + 0.5, segments[^1].End.X);
|
||||
Assert.Equal(scenario.Goal.Y, segments[^1].End.Y);
|
||||
Assert.Equal(scenario.Goal.Z + 0.5, segments[^1].End.Z);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("linear-ascend-gap3-dy+1", 3, 1)]
|
||||
[InlineData("linear-descend-gap5-dy-1", 5, -1)]
|
||||
[InlineData("linear-descend-gap5-dy-2", 5, -2)]
|
||||
public void AStar_LinearRejectedCases_RejectBeforeExecution(string scenarioId, int gap, int deltaY)
|
||||
{
|
||||
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create(scenarioId, gap, deltaY);
|
||||
PathResult result = PathingScenarioRunner.PlanOnly(scenario);
|
||||
|
||||
Assert.Equal(PathStatus.Failed, result.Status);
|
||||
Assert.Empty(PathSegmentBuilder.FromPath(result.Path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AStar_LiveCoordinateLinearDescendGap3DyMinus2_PlansThroughAllThreeJumps()
|
||||
{
|
||||
const int baseX = 100;
|
||||
const int baseY = 80;
|
||||
const int baseZ = 180;
|
||||
|
||||
World world = BuildLiveLinearWorld(baseX, baseY, baseZ, gap: 3, deltaY: -2, segments: 3);
|
||||
var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true);
|
||||
var finder = new AStarPathFinder();
|
||||
|
||||
PathResult result = finder.Calculate(
|
||||
ctx,
|
||||
startX: baseX,
|
||||
startY: baseY,
|
||||
startZ: baseZ,
|
||||
new GoalBlock(115, 74, 180),
|
||||
CancellationToken.None,
|
||||
timeoutMs: 2000);
|
||||
|
||||
List<PathSegment> segments = PathSegmentBuilder.FromPath(result.Path);
|
||||
|
||||
Assert.Equal(PathStatus.Success, result.Status);
|
||||
Assert.NotEmpty(segments);
|
||||
Assert.Equal(new Location(115.5, 74, 180.5), segments[^1].End);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PathSegmentManager_LiveCoordinateLinearFlatGap2_CompletesWithoutReplan()
|
||||
{
|
||||
const int baseX = 100;
|
||||
const int baseY = 80;
|
||||
const int baseZ = 297;
|
||||
|
||||
World world = BuildLiveLinearWorld(baseX, baseY, baseZ, gap: 2, deltaY: 0, segments: 3);
|
||||
var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true);
|
||||
var finder = new AStarPathFinder();
|
||||
PathResult planResult = finder.Calculate(
|
||||
ctx,
|
||||
startX: baseX,
|
||||
startY: baseY,
|
||||
startZ: baseZ,
|
||||
new GoalBlock(112, 80, 297),
|
||||
CancellationToken.None,
|
||||
timeoutMs: 2000);
|
||||
|
||||
var debugLogs = new List<string>();
|
||||
var infoLogs = new List<string>();
|
||||
var manager = new PathSegmentManager(debugLogs.Add, infoLogs.Add);
|
||||
var physics = TemplateSimulationRunner.CreateGroundedPhysics(new Location(baseX + 0.5, baseY, baseZ + 0.5), yaw: 270f);
|
||||
var input = new MovementInput();
|
||||
var recentTrace = new Queue<string>();
|
||||
Location finalPos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
|
||||
Assert.Equal(PathStatus.Success, planResult.Status);
|
||||
manager.StartNavigation(new GoalBlock(112, 80, 297), planResult);
|
||||
|
||||
for (int tick = 0; tick < 200 && manager.IsNavigating; tick++)
|
||||
{
|
||||
input.Reset();
|
||||
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
manager.Tick(pos, physics, input, world);
|
||||
recentTrace.Enqueue(
|
||||
$"tick={tick} pos={pos} vel={physics.DeltaMovement} yaw={physics.Yaw:F1} onGround={physics.OnGround} " +
|
||||
$"input(F={input.Forward},B={input.Back},J={input.Jump},S={input.Sprint})");
|
||||
if (recentTrace.Count > 80)
|
||||
recentTrace.Dequeue();
|
||||
|
||||
if (!manager.IsNavigating)
|
||||
break;
|
||||
|
||||
physics.ApplyInput(input);
|
||||
physics.Tick(world);
|
||||
finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
!manager.IsNavigating
|
||||
&& manager.Goal is null
|
||||
&& manager.ReplanCount == 0
|
||||
&& TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, new Location(112.5, 80, 297.5)),
|
||||
$"replans={manager.ReplanCount} final={finalPos}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}\ntrace={string.Join('\n', recentTrace)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PathSegmentManager_LiveCoordinateLinearDescendGap3DyMinus2_CompletesWithoutReplan()
|
||||
{
|
||||
const int baseX = 100;
|
||||
const int baseY = 80;
|
||||
const int baseZ = 180;
|
||||
|
||||
World world = BuildLiveLinearWorld(baseX, baseY, baseZ, gap: 3, deltaY: -2, segments: 3);
|
||||
var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true);
|
||||
var finder = new AStarPathFinder();
|
||||
PathResult planResult = finder.Calculate(
|
||||
ctx,
|
||||
startX: baseX,
|
||||
startY: baseY,
|
||||
startZ: baseZ,
|
||||
new GoalBlock(115, 74, 180),
|
||||
CancellationToken.None,
|
||||
timeoutMs: 2000);
|
||||
|
||||
var debugLogs = new List<string>();
|
||||
var infoLogs = new List<string>();
|
||||
var manager = new PathSegmentManager(debugLogs.Add, infoLogs.Add);
|
||||
var physics = TemplateSimulationRunner.CreateGroundedPhysics(new Location(baseX + 0.5, baseY, baseZ + 0.5), yaw: 270f);
|
||||
var input = new MovementInput();
|
||||
var recentTrace = new Queue<string>();
|
||||
Location finalPos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
|
||||
Assert.Equal(PathStatus.Success, planResult.Status);
|
||||
manager.StartNavigation(new GoalBlock(115, 74, 180), planResult);
|
||||
|
||||
for (int tick = 0; tick < 240 && manager.IsNavigating; tick++)
|
||||
{
|
||||
input.Reset();
|
||||
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
manager.Tick(pos, physics, input, world);
|
||||
recentTrace.Enqueue(
|
||||
$"tick={tick} pos={pos} vel={physics.DeltaMovement} yaw={physics.Yaw:F1} onGround={physics.OnGround} " +
|
||||
$"input(F={input.Forward},B={input.Back},J={input.Jump},S={input.Sprint})");
|
||||
if (recentTrace.Count > 100)
|
||||
recentTrace.Dequeue();
|
||||
|
||||
if (!manager.IsNavigating)
|
||||
break;
|
||||
|
||||
physics.ApplyInput(input);
|
||||
physics.Tick(world);
|
||||
finalPos = new Location(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
!manager.IsNavigating
|
||||
&& manager.Goal is null
|
||||
&& manager.ReplanCount == 0
|
||||
&& TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, new Location(115.5, 74, 180.5)),
|
||||
$"replans={manager.ReplanCount} final={finalPos}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}\ntrace={string.Join('\n', recentTrace)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PathSegmentManager_LinearDescendGap0DyMinus2_DoesNotTurnInPlace()
|
||||
{
|
||||
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create("linear-descend-gap0-dy-2", gap: 0, deltaY: -2);
|
||||
PathResult planResult = PathingScenarioRunner.PlanOnly(scenario);
|
||||
World world = scenario.BuildWorld();
|
||||
var debugLogs = new List<string>();
|
||||
var infoLogs = new List<string>();
|
||||
var manager = new PathSegmentManager(debugLogs.Add, infoLogs.Add);
|
||||
var physics = TemplateSimulationRunner.CreateGroundedPhysics(scenario.Start, scenario.StartYaw);
|
||||
var input = new MovementInput();
|
||||
var samples = new List<TurnSample>();
|
||||
|
||||
Assert.Equal(PathStatus.Success, planResult.Status);
|
||||
manager.StartNavigation(scenario.Goal, planResult);
|
||||
|
||||
for (int tick = 0; tick < scenario.MaxExecutionTicks && manager.IsNavigating; tick++)
|
||||
{
|
||||
input.Reset();
|
||||
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
manager.Tick(pos, physics, input, world);
|
||||
|
||||
physics.ApplyInput(input);
|
||||
physics.Tick(world);
|
||||
|
||||
samples.Add(new TurnSample(physics.Position.X, physics.Position.Y, physics.Position.Z, physics.Yaw));
|
||||
}
|
||||
|
||||
Assert.True(!manager.IsNavigating && manager.Goal is null && manager.ReplanCount == 0,
|
||||
$"replans={manager.ReplanCount}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}");
|
||||
int turnStalls = CountTurnStalls(samples, out string stallTrace);
|
||||
Assert.True(turnStalls == 0, $"turnStalls={turnStalls}\n{stallTrace}");
|
||||
}
|
||||
|
||||
private readonly record struct TurnSample(double X, double Y, double Z, float Yaw);
|
||||
|
||||
private static int CountTurnStalls(IReadOnlyList<TurnSample> samples, out string trace)
|
||||
{
|
||||
const int MinSamples = 4;
|
||||
const double WindowMaxTravel = 0.35;
|
||||
const double MinCumulativeYaw = 180.0;
|
||||
const double MinPerStepYaw = 35.0;
|
||||
var traces = new List<string>();
|
||||
|
||||
if (samples.Count < MinSamples)
|
||||
{
|
||||
trace = string.Empty;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static double NormalizeYawDelta(float previousYaw, float currentYaw)
|
||||
{
|
||||
double delta = (currentYaw - previousYaw + 180.0) % 360.0 - 180.0;
|
||||
return Math.Abs(delta);
|
||||
}
|
||||
|
||||
static double HorizontalDistance(in TurnSample a, in TurnSample b)
|
||||
{
|
||||
double dx = a.X - b.X;
|
||||
double dz = a.Z - b.Z;
|
||||
return Math.Sqrt(dx * dx + dz * dz);
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
int windowStart = 0;
|
||||
while (windowStart <= samples.Count - MinSamples)
|
||||
{
|
||||
TurnSample baseSample = samples[windowStart];
|
||||
double cumulativeYaw = 0.0;
|
||||
int largeSwings = 0;
|
||||
bool matched = false;
|
||||
|
||||
for (int idx = windowStart + 1; idx < samples.Count; idx++)
|
||||
{
|
||||
TurnSample sample = samples[idx];
|
||||
if (HorizontalDistance(baseSample, sample) > WindowMaxTravel)
|
||||
break;
|
||||
|
||||
double yawDelta = NormalizeYawDelta(samples[idx - 1].Yaw, sample.Yaw);
|
||||
cumulativeYaw += yawDelta;
|
||||
if (yawDelta >= MinPerStepYaw)
|
||||
largeSwings++;
|
||||
|
||||
int sampleCount = idx - windowStart + 1;
|
||||
if (sampleCount >= MinSamples
|
||||
&& largeSwings >= MinSamples - 1
|
||||
&& cumulativeYaw >= MinCumulativeYaw)
|
||||
{
|
||||
var windowSamples = new List<string>();
|
||||
for (int traceIdx = windowStart; traceIdx <= idx; traceIdx++)
|
||||
{
|
||||
TurnSample traceSample = samples[traceIdx];
|
||||
windowSamples.Add($"#{traceIdx}=({traceSample.X:F2},{traceSample.Y:F2},{traceSample.Z:F2},{traceSample.Yaw:F1})");
|
||||
}
|
||||
traces.Add(
|
||||
$"start={windowStart} end={idx} base=({baseSample.X:F2},{baseSample.Y:F2},{baseSample.Z:F2},{baseSample.Yaw:F1}) " +
|
||||
$"last=({sample.X:F2},{sample.Y:F2},{sample.Z:F2},{sample.Yaw:F1}) yaw={cumulativeYaw:F1}\n" +
|
||||
string.Join(' ', windowSamples));
|
||||
count++;
|
||||
windowStart = idx + 1;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched)
|
||||
windowStart++;
|
||||
}
|
||||
|
||||
trace = string.Join('\n', traces);
|
||||
return count;
|
||||
}
|
||||
|
||||
private static World BuildLiveLinearWorld(int baseX, int baseY, int baseZ, int gap, int deltaY, int segments)
|
||||
{
|
||||
int endX = baseX + 3 + ((gap + 1) * segments);
|
||||
int min = Math.Min(baseX - 8, baseZ - 8);
|
||||
int max = Math.Max(endX + 8, baseZ + 8);
|
||||
World world = FlatWorldTestBuilder.CreateStoneFloor(floorY: 0, min: min, max: max);
|
||||
FlatWorldTestBuilder.ClearBox(world, baseX - 8, 1, baseZ - 2, endX + 8, baseY + 12, baseZ + 2);
|
||||
|
||||
int floorY = baseY - 1;
|
||||
FlatWorldTestBuilder.FillSolid(world, baseX, floorY, baseZ, baseX + 3, floorY, baseZ);
|
||||
|
||||
int lastX = baseX + 3;
|
||||
int lastFloorY = floorY;
|
||||
for (int segment = 0; segment < segments; segment++)
|
||||
{
|
||||
int platformX = lastX + gap + 1;
|
||||
int platformY = lastFloorY + deltaY;
|
||||
FlatWorldTestBuilder.SetSolid(world, platformX, platformY, baseZ);
|
||||
lastX = platformX;
|
||||
lastFloorY = platformY;
|
||||
}
|
||||
|
||||
return world;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ using MinecraftClient.Mapping;
|
|||
using MinecraftClient.Pathing.Core;
|
||||
using MinecraftClient.Pathing.Execution;
|
||||
using MinecraftClient.Physics;
|
||||
using System.Reflection;
|
||||
using Xunit;
|
||||
|
||||
namespace MinecraftClient.Tests.Pathing.Execution;
|
||||
|
|
@ -46,6 +47,105 @@ public sealed class PathExecutorCompletionTests
|
|||
Assert.False(input.Back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_PreservesCarryInput_WhenAdvancingIntoNextSegment()
|
||||
{
|
||||
var executor = new PathExecutor(new List<PathSegment>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Start = new Location(0.5, 80, 0.5),
|
||||
End = new Location(1.5, 80, 0.5),
|
||||
MoveType = MoveType.Traverse,
|
||||
ExitTransition = PathTransitionType.PrepareJump,
|
||||
ExitHints = new PathTransitionHints(1, 0, 0.10, double.PositiveInfinity, false, true, true, false, 10),
|
||||
PreserveSprint = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Start = new Location(1.5, 80, 0.5),
|
||||
End = new Location(4.5, 80, 0.5),
|
||||
MoveType = MoveType.Parkour,
|
||||
ExitTransition = PathTransitionType.FinalStop
|
||||
}
|
||||
});
|
||||
|
||||
var physics = new PlayerPhysics
|
||||
{
|
||||
Position = new Vec3d(1.48, 80.00, 0.50),
|
||||
DeltaMovement = new Vec3d(0.1178, -0.0784, 0.0),
|
||||
OnGround = true,
|
||||
MovementSpeed = 0.1f,
|
||||
Yaw = 270f,
|
||||
Pitch = 0f
|
||||
};
|
||||
var input = new MovementInput();
|
||||
World world = FlatWorldTestBuilder.CreateStoneFloor(min: -2, max: 8);
|
||||
|
||||
PathExecutorState state = executor.Tick(new Location(physics.Position.X, physics.Position.Y, physics.Position.Z), physics, input, world);
|
||||
|
||||
Assert.Equal(PathExecutorState.InProgress, state);
|
||||
Assert.Equal(1, executor.CurrentIndex);
|
||||
Assert.True(input.Forward, $"input(F={input.Forward},S={input.Sprint},J={input.Jump},B={input.Back})");
|
||||
Assert.True(input.Sprint, $"input(F={input.Forward},S={input.Sprint},J={input.Jump},B={input.Back})");
|
||||
Assert.False(input.Back, $"input(F={input.Forward},S={input.Sprint},J={input.Jump},B={input.Back})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_AdvanceFromParkourIntoParkour_IssuesJumpOnSameTick()
|
||||
{
|
||||
var executor = new PathExecutor(new List<PathSegment>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Start = new Location(0.5, 80, 0.5),
|
||||
End = new Location(3.5, 80, 0.5),
|
||||
MoveType = MoveType.Parkour,
|
||||
ExitTransition = PathTransitionType.PrepareJump,
|
||||
ExitHints = new PathTransitionHints(1, 0, 0.10, double.PositiveInfinity, false, true, true, false, 10),
|
||||
PreserveSprint = true
|
||||
},
|
||||
new()
|
||||
{
|
||||
Start = new Location(3.5, 80, 0.5),
|
||||
End = new Location(6.5, 80, 0.5),
|
||||
MoveType = MoveType.Parkour,
|
||||
ExitTransition = PathTransitionType.FinalStop
|
||||
}
|
||||
});
|
||||
|
||||
SetCurrentTemplate(
|
||||
executor,
|
||||
new CompletingTemplate(
|
||||
new Location(0.5, 80, 0.5),
|
||||
new Location(3.5, 80, 0.5)));
|
||||
|
||||
var physics = new PlayerPhysics
|
||||
{
|
||||
Position = new Vec3d(3.50, 80.00, 0.50),
|
||||
DeltaMovement = new Vec3d(0.2200, 0.0, 0.0),
|
||||
OnGround = true,
|
||||
MovementSpeed = 0.1f,
|
||||
Yaw = 270f,
|
||||
Pitch = 0f
|
||||
};
|
||||
var input = new MovementInput();
|
||||
World world = FlatWorldTestBuilder.CreateStoneFloor(min: -2, max: 10);
|
||||
FlatWorldTestBuilder.ClearBox(world, 1, 79, 0, 6, 82, 1);
|
||||
FlatWorldTestBuilder.SetSolid(world, 0, 79, 0);
|
||||
FlatWorldTestBuilder.SetSolid(world, 3, 79, 0);
|
||||
FlatWorldTestBuilder.SetSolid(world, 6, 79, 0);
|
||||
|
||||
PathExecutorState state = executor.Tick(new Location(physics.Position.X, physics.Position.Y, physics.Position.Z), physics, input, world);
|
||||
|
||||
Assert.Equal(PathExecutorState.InProgress, state);
|
||||
Assert.Equal(1, executor.CurrentIndex);
|
||||
Assert.True(input.Forward, $"input(F={input.Forward},S={input.Sprint},J={input.Jump},B={input.Back})");
|
||||
Assert.True(input.Sprint, $"input(F={input.Forward},S={input.Sprint},J={input.Jump},B={input.Back})");
|
||||
Assert.True(input.Jump, $"input(F={input.Forward},S={input.Sprint},J={input.Jump},B={input.Back})");
|
||||
Assert.False(input.Back, $"input(F={input.Forward},S={input.Sprint},J={input.Jump},B={input.Back})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_CompletesStraightThreeSegmentFlatPath()
|
||||
{
|
||||
|
|
@ -115,6 +215,99 @@ public sealed class PathExecutorCompletionTests
|
|||
Assert.True(state == PathExecutorState.Complete, $"state={state}\n{string.Join('\n', debugLogs)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_LinearDescendGap0DyMinus2_DoesNotTurnInPlace()
|
||||
{
|
||||
List<PathSegment> segments = PathSegmentBuilder.FromPath(BuildNodes(
|
||||
(0, 80, 0, MoveType.Traverse),
|
||||
(1, 80, 0, MoveType.Traverse),
|
||||
(2, 80, 0, MoveType.Traverse),
|
||||
(3, 80, 0, MoveType.Traverse),
|
||||
(4, 78, 0, MoveType.Descend),
|
||||
(5, 76, 0, MoveType.Descend),
|
||||
(6, 74, 0, MoveType.Descend)));
|
||||
|
||||
var debugLogs = new List<string>();
|
||||
var executor = new PathExecutor(segments, debugLogs.Add);
|
||||
World world = LinearParkourScenarioBuilder.Create("linear-descend-gap0-dy-2", gap: 0, deltaY: -2).BuildWorld();
|
||||
var physics = TemplateSimulationRunner.CreateGroundedPhysics(new Location(0.5, 80, 0.5), yaw: 270f);
|
||||
var input = new MovementInput();
|
||||
var samples = new List<TurnSample>();
|
||||
|
||||
PathExecutorState state = PathExecutorState.InProgress;
|
||||
for (int tick = 0; tick < 420; tick++)
|
||||
{
|
||||
input.Reset();
|
||||
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
state = executor.Tick(pos, physics, input, world);
|
||||
if (state != PathExecutorState.InProgress)
|
||||
break;
|
||||
|
||||
physics.ApplyInput(input);
|
||||
physics.Tick(world);
|
||||
samples.Add(new TurnSample(physics.Position.X, physics.Position.Y, physics.Position.Z, physics.Yaw));
|
||||
}
|
||||
|
||||
int turnStalls = CountTurnStalls(samples, out string stallTrace);
|
||||
|
||||
Assert.True(state == PathExecutorState.Complete, $"state={state}\n{string.Join('\n', debugLogs)}");
|
||||
Assert.True(turnStalls == 0, $"turnStalls={turnStalls}\n{stallTrace}\n{string.Join('\n', debugLogs)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_PlannedLinearDescendGap0DyMinus2_DoesNotTurnInPlace()
|
||||
{
|
||||
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create("linear-descend-gap0-dy-2", gap: 0, deltaY: -2);
|
||||
PathResult planResult = PathingScenarioRunner.PlanOnly(scenario);
|
||||
List<PathSegment> segments = PathSegmentBuilder.FromPath(planResult.Path);
|
||||
|
||||
var debugLogs = new List<string>();
|
||||
var executor = new PathExecutor(segments, debugLogs.Add);
|
||||
World world = scenario.BuildWorld();
|
||||
var physics = TemplateSimulationRunner.CreateGroundedPhysics(scenario.Start, yaw: scenario.StartYaw);
|
||||
var input = new MovementInput();
|
||||
var samples = new List<TurnSample>();
|
||||
|
||||
PathExecutorState state = PathExecutorState.InProgress;
|
||||
for (int tick = 0; tick < scenario.MaxExecutionTicks; tick++)
|
||||
{
|
||||
input.Reset();
|
||||
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
state = executor.Tick(pos, physics, input, world);
|
||||
if (state != PathExecutorState.InProgress)
|
||||
break;
|
||||
|
||||
physics.ApplyInput(input);
|
||||
physics.Tick(world);
|
||||
samples.Add(new TurnSample(physics.Position.X, physics.Position.Y, physics.Position.Z, physics.Yaw));
|
||||
}
|
||||
|
||||
int turnStalls = CountTurnStalls(samples, out string stallTrace);
|
||||
string segmentTrace = string.Join('\n', segments.ConvertAll(static segment => segment.ToString()));
|
||||
|
||||
Assert.Equal(PathStatus.Success, planResult.Status);
|
||||
Assert.True(state == PathExecutorState.Complete, $"state={state}\nsegments:\n{segmentTrace}\n{string.Join('\n', debugLogs)}");
|
||||
Assert.True(turnStalls == 0, $"turnStalls={turnStalls}\n{stallTrace}\nsegments:\n{segmentTrace}\n{string.Join('\n', debugLogs)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_LinearFlatGap2_CompletesWithoutFailure()
|
||||
{
|
||||
AssertLinearScenarioCompletes("linear-flat-gap2", gap: 2, deltaY: 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_LinearAscendGap2DyPlus1_CompletesWithoutFailure()
|
||||
{
|
||||
AssertLinearScenarioCompletes("linear-ascend-gap2-dy+1", gap: 2, deltaY: 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_LinearDescendGap2DyMinus1_CompletesWithoutFailure()
|
||||
{
|
||||
AssertLinearScenarioCompletes("linear-descend-gap2-dy-1", gap: 2, deltaY: -1);
|
||||
}
|
||||
|
||||
private static List<PathNode> BuildNodes(params (int x, int y, int z, MoveType moveUsed)[] raw)
|
||||
{
|
||||
var result = new List<PathNode>(raw.Length);
|
||||
|
|
@ -128,4 +321,128 @@ public sealed class PathExecutorCompletionTests
|
|||
|
||||
return result;
|
||||
}
|
||||
|
||||
private readonly record struct TurnSample(double X, double Y, double Z, float Yaw);
|
||||
|
||||
private sealed class CompletingTemplate(Location start, Location end) : IActionTemplate
|
||||
{
|
||||
public Location ExpectedStart { get; } = start;
|
||||
public Location ExpectedEnd { get; } = end;
|
||||
|
||||
public TemplateState Tick(Location currentPos, PlayerPhysics physics, MovementInput input, World world)
|
||||
{
|
||||
return TemplateState.Complete;
|
||||
}
|
||||
}
|
||||
|
||||
private static int CountTurnStalls(IReadOnlyList<TurnSample> samples, out string trace)
|
||||
{
|
||||
const int MinSamples = 4;
|
||||
const double WindowMaxTravel = 0.35;
|
||||
const double MinCumulativeYaw = 180.0;
|
||||
const double MinPerStepYaw = 35.0;
|
||||
var traces = new List<string>();
|
||||
|
||||
if (samples.Count < MinSamples)
|
||||
{
|
||||
trace = string.Empty;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static double NormalizeYawDelta(float previousYaw, float currentYaw)
|
||||
{
|
||||
double delta = (currentYaw - previousYaw + 180.0) % 360.0 - 180.0;
|
||||
return Math.Abs(delta);
|
||||
}
|
||||
|
||||
static double HorizontalDistance(in TurnSample a, in TurnSample b)
|
||||
{
|
||||
double dx = a.X - b.X;
|
||||
double dz = a.Z - b.Z;
|
||||
return Math.Sqrt(dx * dx + dz * dz);
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
int windowStart = 0;
|
||||
while (windowStart <= samples.Count - MinSamples)
|
||||
{
|
||||
TurnSample baseSample = samples[windowStart];
|
||||
double cumulativeYaw = 0.0;
|
||||
int largeSwings = 0;
|
||||
bool matched = false;
|
||||
|
||||
for (int idx = windowStart + 1; idx < samples.Count; idx++)
|
||||
{
|
||||
TurnSample sample = samples[idx];
|
||||
if (HorizontalDistance(baseSample, sample) > WindowMaxTravel)
|
||||
break;
|
||||
|
||||
double yawDelta = NormalizeYawDelta(samples[idx - 1].Yaw, sample.Yaw);
|
||||
cumulativeYaw += yawDelta;
|
||||
if (yawDelta >= MinPerStepYaw)
|
||||
largeSwings++;
|
||||
|
||||
int sampleCount = idx - windowStart + 1;
|
||||
if (sampleCount >= MinSamples
|
||||
&& largeSwings >= MinSamples - 1
|
||||
&& cumulativeYaw >= MinCumulativeYaw)
|
||||
{
|
||||
traces.Add(
|
||||
$"start={windowStart} end={idx} base=({baseSample.X:F2},{baseSample.Y:F2},{baseSample.Z:F2},{baseSample.Yaw:F1}) " +
|
||||
$"last=({sample.X:F2},{sample.Y:F2},{sample.Z:F2},{sample.Yaw:F1}) yaw={cumulativeYaw:F1}");
|
||||
count++;
|
||||
windowStart = idx + 1;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched)
|
||||
windowStart++;
|
||||
}
|
||||
|
||||
trace = string.Join('\n', traces);
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void SetCurrentTemplate(PathExecutor executor, IActionTemplate template)
|
||||
{
|
||||
FieldInfo field = typeof(PathExecutor).GetField("_currentTemplate", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
?? throw new InvalidOperationException("PathExecutor._currentTemplate not found");
|
||||
field.SetValue(executor, template);
|
||||
}
|
||||
|
||||
private static void AssertLinearScenarioCompletes(string scenarioId, int gap, int deltaY)
|
||||
{
|
||||
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create(scenarioId, gap, deltaY);
|
||||
PathResult planResult = PathingScenarioRunner.PlanOnly(scenario);
|
||||
List<PathSegment> segments = PathSegmentBuilder.FromPath(planResult.Path);
|
||||
|
||||
var debugLogs = new List<string>();
|
||||
var executor = new PathExecutor(segments, debugLogs.Add);
|
||||
World world = scenario.BuildWorld();
|
||||
var physics = TemplateSimulationRunner.CreateGroundedPhysics(scenario.Start, scenario.StartYaw);
|
||||
var input = new MovementInput();
|
||||
|
||||
PathExecutorState state = PathExecutorState.InProgress;
|
||||
for (int tick = 0; tick < scenario.MaxExecutionTicks; tick++)
|
||||
{
|
||||
input.Reset();
|
||||
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
state = executor.Tick(pos, physics, input, world);
|
||||
if (state != PathExecutorState.InProgress)
|
||||
break;
|
||||
|
||||
physics.ApplyInput(input);
|
||||
physics.Tick(world);
|
||||
}
|
||||
|
||||
Location finalPos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
string segmentTrace = string.Join('\n', segments.ConvertAll(static segment => segment.ToString()));
|
||||
|
||||
Assert.Equal(PathStatus.Success, planResult.Status);
|
||||
Assert.True(
|
||||
state == PathExecutorState.Complete,
|
||||
$"scenario={scenarioId} state={state} finalPos={finalPos} vel={physics.DeltaMovement}\nsegments:\n{segmentTrace}\n{string.Join('\n', debugLogs)}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using MinecraftClient.Mapping;
|
||||
using MinecraftClient.Pathing.Core;
|
||||
using MinecraftClient.Pathing.Execution;
|
||||
using MinecraftClient.Pathing.Execution.Templates;
|
||||
using MinecraftClient.Pathing.Goals;
|
||||
using MinecraftClient.Physics;
|
||||
using Xunit;
|
||||
|
|
@ -224,6 +225,95 @@ public sealed class PathSegmentManagerTests
|
|||
$"replanCount={manager.ReplanCount}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(LinearParkourScenarioBuilder.AcceptedCases), MemberType = typeof(LinearParkourScenarioBuilder))]
|
||||
public void Tick_LinearAcceptedChain_CompletesWithoutReplan(string scenarioId, int gap, int deltaY)
|
||||
{
|
||||
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create(scenarioId, gap, deltaY);
|
||||
World world = scenario.BuildWorld();
|
||||
var debugLogs = new List<string>();
|
||||
var infoLogs = new List<string>();
|
||||
var observer = new RecordingPathExecutionObserver();
|
||||
var manager = new PathSegmentManager(debugLogs.Add, infoLogs.Add, observer);
|
||||
var physics = TemplateSimulationRunner.CreateGroundedPhysics(scenario.Start, scenario.StartYaw);
|
||||
var input = new MovementInput();
|
||||
var recentTrace = new Queue<string>();
|
||||
|
||||
PathResult planResult = PathingScenarioRunner.PlanOnly(scenario);
|
||||
manager.StartNavigation(scenario.Goal, planResult);
|
||||
|
||||
for (int tick = 0; tick < scenario.MaxExecutionTicks && manager.IsNavigating; tick++)
|
||||
{
|
||||
input.Reset();
|
||||
Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
manager.Tick(pos, physics, input, world);
|
||||
recentTrace.Enqueue(
|
||||
$"tick={tick} pos={pos} vel={physics.DeltaMovement} yaw={physics.Yaw:F1} onGround={physics.OnGround} " +
|
||||
$"input(F={input.Forward},B={input.Back},J={input.Jump},S={input.Sprint})");
|
||||
if (recentTrace.Count > 60)
|
||||
recentTrace.Dequeue();
|
||||
|
||||
if (!manager.IsNavigating)
|
||||
break;
|
||||
|
||||
physics.ApplyInput(input);
|
||||
physics.Tick(world);
|
||||
}
|
||||
|
||||
Location finalPosition = new(physics.Position.X, physics.Position.Y, physics.Position.Z);
|
||||
Location goalLocation = new(scenario.Goal.X + 0.5, scenario.Goal.Y, scenario.Goal.Z + 0.5);
|
||||
|
||||
Assert.True(
|
||||
!manager.IsNavigating
|
||||
&& manager.Goal is null
|
||||
&& observer.ReplanCount == 0
|
||||
&& TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPosition, goalLocation),
|
||||
$"scenario={scenarioId} completed={!manager.IsNavigating && manager.Goal is null} replans={observer.ReplanCount} final={finalPosition} " +
|
||||
$"goal={goalLocation} planStatus={planResult.Status}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}\ntrace={string.Join('\n', recentTrace)}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_LinearFlatGap2_CompletesWithoutReplan()
|
||||
{
|
||||
AssertLinearScenarioCompletesWithoutReplan("linear-flat-gap2", gap: 2, deltaY: 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_LinearAscendGap2DyPlus1_CompletesWithoutReplan()
|
||||
{
|
||||
AssertLinearScenarioCompletesWithoutReplan("linear-ascend-gap2-dy+1", gap: 2, deltaY: 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_LinearAscendGap3DyPlus1_CompletesWithoutReplan()
|
||||
{
|
||||
AssertLinearScenarioRejected("linear-ascend-gap3-dy+1", gap: 3, deltaY: 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_LinearDescendGap2DyMinus1_CompletesWithoutReplan()
|
||||
{
|
||||
AssertLinearScenarioCompletesWithoutReplan("linear-descend-gap2-dy-1", gap: 2, deltaY: -1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_LinearDescendGap3DyMinus2_CompletesWithoutReplan()
|
||||
{
|
||||
AssertLinearScenarioCompletesWithoutReplan("linear-descend-gap3-dy-2", gap: 3, deltaY: -2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_LinearDescendGap5DyMinus1_CompletesWithoutReplan()
|
||||
{
|
||||
AssertLinearScenarioRejected("linear-descend-gap5-dy-1", gap: 5, deltaY: -1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_LinearDescendGap5DyMinus2_CompletesWithoutReplan()
|
||||
{
|
||||
AssertLinearScenarioRejected("linear-descend-gap5-dy-2", gap: 5, deltaY: -2);
|
||||
}
|
||||
|
||||
private static List<PathNode> BuildNodes(params (int x, int y, int z, MoveType moveUsed)[] raw)
|
||||
{
|
||||
var result = new List<PathNode>(raw.Length);
|
||||
|
|
@ -237,4 +327,36 @@ public sealed class PathSegmentManagerTests
|
|||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AssertLinearScenarioCompletesWithoutReplan(string scenarioId, int gap, int deltaY)
|
||||
{
|
||||
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create(scenarioId, gap, deltaY);
|
||||
PathingScenarioResult result = PathingScenarioRunner.RunAccepted(scenario);
|
||||
Location goalLocation = new(scenario.Goal.X + 0.5, scenario.Goal.Y, scenario.Goal.Z + 0.5);
|
||||
|
||||
Assert.True(
|
||||
result.Completed
|
||||
&& result.ReplanCount == 0
|
||||
&& TemplateFootingHelper.IsFootprintInsideTargetBlock(result.FinalPosition, goalLocation),
|
||||
$"scenario={scenarioId} completed={result.Completed} replans={result.ReplanCount} final={result.FinalPosition} " +
|
||||
$"goal={goalLocation} planStatus={result.PlanResult.Status}\ninfo={string.Join('\n', result.InfoLogs)}\ndebug={string.Join('\n', result.DebugLogs)}");
|
||||
}
|
||||
|
||||
private static void AssertLinearScenarioRejected(string scenarioId, int gap, int deltaY)
|
||||
{
|
||||
PathingExecutionScenario scenario = LinearParkourScenarioBuilder.Create(scenarioId, gap, deltaY);
|
||||
PathResult result = PathingScenarioRunner.PlanOnly(scenario);
|
||||
var infoLogs = new List<string>();
|
||||
var manager = new PathSegmentManager(infoLog: infoLogs.Add);
|
||||
|
||||
manager.StartNavigation(scenario.Goal, result);
|
||||
|
||||
Assert.True(
|
||||
result.Status == PathStatus.Failed
|
||||
&& !manager.IsNavigating
|
||||
&& manager.Goal is null
|
||||
&& manager.ReplanCount == 0
|
||||
&& infoLogs.Exists(log => log.Contains("no path found", StringComparison.OrdinalIgnoreCase)),
|
||||
$"scenario={scenarioId} planStatus={result.Status}\npath={string.Join('\n', PathSegmentBuilder.FromPath(result.Path))}\ninfo={string.Join('\n', infoLogs)}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
using MinecraftClient.Mapping;
|
||||
using MinecraftClient.Pathing.Goals;
|
||||
|
||||
namespace MinecraftClient.Tests.Pathing.Execution;
|
||||
|
||||
public static class LinearParkourScenarioBuilder
|
||||
{
|
||||
private const int SegmentCount = 3;
|
||||
private const int BaseY = 80;
|
||||
private const int FloorY = BaseY - 1;
|
||||
|
||||
public static IEnumerable<object[]> AcceptedCases()
|
||||
{
|
||||
yield return ["linear-ascend-gap1-dy+1", 1, 1];
|
||||
yield return ["linear-ascend-gap2-dy+1", 2, 1];
|
||||
yield return ["linear-descend-gap2-dy-2", 2, -2];
|
||||
yield return ["linear-descend-gap3-dy-1", 3, -1];
|
||||
yield return ["linear-descend-gap4-dy-1", 4, -1];
|
||||
yield return ["linear-flat-gap1", 1, 0];
|
||||
yield return ["linear-flat-gap4", 4, 0];
|
||||
}
|
||||
|
||||
internal static PathingExecutionScenario Create(string scenarioId, int gap, int deltaY, int maxExecutionTicks = 600)
|
||||
{
|
||||
int endX = 3 + ((gap + 1) * SegmentCount);
|
||||
int endFloorY = FloorY + (deltaY * SegmentCount);
|
||||
|
||||
return new PathingExecutionScenario
|
||||
{
|
||||
Id = scenarioId,
|
||||
BuildWorld = () => BuildWorld(gap, deltaY),
|
||||
Start = new Location(0.5, BaseY, 0.5),
|
||||
Goal = new GoalBlock(endX, endFloorY + 1, 0),
|
||||
StartYaw = 270f,
|
||||
MaxExecutionTicks = maxExecutionTicks,
|
||||
};
|
||||
}
|
||||
|
||||
internal static World BuildWorld(int gap, int deltaY)
|
||||
{
|
||||
int endX = 3 + ((gap + 1) * SegmentCount);
|
||||
World world = FlatWorldTestBuilder.CreateStoneFloor(floorY: 0, min: -8, max: endX + 8);
|
||||
FlatWorldTestBuilder.ClearBox(world, -8, 1, -2, endX + 8, BaseY + 12, 2);
|
||||
FlatWorldTestBuilder.FillSolid(world, 0, FloorY, 0, 3, FloorY, 0);
|
||||
|
||||
int lastX = 3;
|
||||
int lastFloorY = FloorY;
|
||||
for (int segment = 0; segment < SegmentCount; segment++)
|
||||
{
|
||||
int platformX = lastX + gap + 1;
|
||||
int platformY = lastFloorY + deltaY;
|
||||
FlatWorldTestBuilder.SetSolid(world, platformX, platformY, 0);
|
||||
lastX = platformX;
|
||||
lastFloorY = platformY;
|
||||
}
|
||||
|
||||
return world;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
using MinecraftClient.Mapping;
|
||||
using MinecraftClient.Pathing.Core;
|
||||
using MinecraftClient.Pathing.Execution;
|
||||
using MinecraftClient.Pathing.Moves.Impl;
|
||||
using MinecraftClient.Tests.Pathing.Execution;
|
||||
using System.Reflection;
|
||||
using Xunit;
|
||||
|
||||
namespace MinecraftClient.Tests.Pathing.Moves;
|
||||
|
|
@ -13,6 +15,16 @@ public sealed class MoveParkourTests
|
|||
private static CalculationContext BuildContext(World world)
|
||||
=> new(world, allowParkour: true, allowParkourAscend: true);
|
||||
|
||||
private static void SetPreviousMoveType(CalculationContext ctx, MoveType moveType)
|
||||
{
|
||||
PropertyInfo? property = typeof(CalculationContext).GetProperty(
|
||||
nameof(CalculationContext.PreviousMoveType),
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
MethodInfo? setter = property?.SetMethod;
|
||||
Assert.NotNull(setter);
|
||||
setter!.Invoke(ctx, [moveType]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects3x1JumpWhenRunUpMissing()
|
||||
{
|
||||
|
|
@ -90,4 +102,111 @@ public sealed class MoveParkourTests
|
|||
|
||||
Assert.True(result.IsImpossible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcceptsCarried4x1DescendingGapFromSingleBlockLanding()
|
||||
{
|
||||
var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
|
||||
FlatWorldTestBuilder.ClearBox(world, -2, FloorY - 2, -1, 6, FloorY + 4, 1);
|
||||
FlatWorldTestBuilder.SetSolid(world, 0, FloorY, 0);
|
||||
FlatWorldTestBuilder.SetSolid(world, 4, FloorY - 1, 0);
|
||||
|
||||
var ctx = BuildContext(world);
|
||||
SetPreviousMoveType(ctx, MoveType.Parkour);
|
||||
var move = new MoveParkour(4, 0, yDelta: -1);
|
||||
var result = default(MoveResult);
|
||||
|
||||
move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
|
||||
|
||||
Assert.False(result.IsImpossible);
|
||||
Assert.Equal(4, result.DestX);
|
||||
Assert.Equal(FloorY, result.DestY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects4x1AscendingGapEvenWithRunway()
|
||||
{
|
||||
var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
|
||||
FlatWorldTestBuilder.ClearBox(world, -2, FloorY, -1, 6, FloorY + 4, 1);
|
||||
FlatWorldTestBuilder.FillSolid(world, -2, FloorY, 0, 0, FloorY, 0);
|
||||
FlatWorldTestBuilder.SetSolid(world, 4, FloorY + 1, 0);
|
||||
|
||||
var ctx = BuildContext(world);
|
||||
var move = new MoveParkour(4, 0, yDelta: 1);
|
||||
var result = default(MoveResult);
|
||||
|
||||
move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
|
||||
|
||||
Assert.True(result.IsImpossible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects6x1DescendingGapEvenWithRunway()
|
||||
{
|
||||
var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
|
||||
FlatWorldTestBuilder.ClearBox(world, -3, FloorY - 1, -1, 8, FloorY + 4, 1);
|
||||
FlatWorldTestBuilder.FillSolid(world, -3, FloorY, 0, 0, FloorY, 0);
|
||||
FlatWorldTestBuilder.SetSolid(world, 6, FloorY - 1, 0);
|
||||
|
||||
var ctx = BuildContext(world);
|
||||
var move = new MoveParkour(6, 0, yDelta: -1);
|
||||
var result = default(MoveResult);
|
||||
|
||||
move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
|
||||
|
||||
Assert.True(result.IsImpossible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects6x2DescendingGapEvenWithRunway()
|
||||
{
|
||||
var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
|
||||
FlatWorldTestBuilder.ClearBox(world, -3, FloorY - 2, -1, 8, FloorY + 4, 1);
|
||||
FlatWorldTestBuilder.FillSolid(world, -3, FloorY, 0, 0, FloorY, 0);
|
||||
FlatWorldTestBuilder.SetSolid(world, 6, FloorY - 2, 0);
|
||||
|
||||
var ctx = BuildContext(world);
|
||||
var move = new MoveParkour(6, 0, yDelta: -2);
|
||||
var result = default(MoveResult);
|
||||
|
||||
move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
|
||||
|
||||
Assert.True(result.IsImpossible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsCarried6x1DescendingGapFromSingleBlockLanding()
|
||||
{
|
||||
var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
|
||||
FlatWorldTestBuilder.ClearBox(world, -2, FloorY - 1, -1, 8, FloorY + 4, 1);
|
||||
FlatWorldTestBuilder.SetSolid(world, 0, FloorY, 0);
|
||||
FlatWorldTestBuilder.SetSolid(world, 6, FloorY - 1, 0);
|
||||
|
||||
var ctx = BuildContext(world);
|
||||
SetPreviousMoveType(ctx, MoveType.Parkour);
|
||||
var move = new MoveParkour(6, 0, yDelta: -1);
|
||||
var result = default(MoveResult);
|
||||
|
||||
move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
|
||||
|
||||
Assert.True(result.IsImpossible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsCarried6x2DescendingGapFromSingleBlockLanding()
|
||||
{
|
||||
var world = FlatWorldTestBuilder.CreateStoneFloor(FloorY);
|
||||
FlatWorldTestBuilder.ClearBox(world, -2, FloorY - 2, -1, 8, FloorY + 4, 1);
|
||||
FlatWorldTestBuilder.SetSolid(world, 0, FloorY, 0);
|
||||
FlatWorldTestBuilder.SetSolid(world, 6, FloorY - 2, 0);
|
||||
|
||||
var ctx = BuildContext(world);
|
||||
SetPreviousMoveType(ctx, MoveType.Parkour);
|
||||
var move = new MoveParkour(6, 0, yDelta: -2);
|
||||
var result = default(MoveResult);
|
||||
|
||||
move.Calculate(ctx, 0, FloorY + 1, 0, ref result);
|
||||
|
||||
Assert.True(result.IsImpossible);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ namespace MinecraftClient.Commands
|
|||
|
||||
var loc = handler.GetCurrentLocation();
|
||||
sb.AppendLine($"§7{Translations.cmd_debug_state_location,-10}§f{loc.X:F2}, {loc.Y:F2}, {loc.Z:F2}");
|
||||
sb.AppendLine($"§7{Translations.cmd_debug_state_on_ground,-10}§f{(handler.GetLocalOnGround() ? "true" : "false")}");
|
||||
|
||||
sb.AppendLine($"§7{Translations.cmd_debug_state_tps,-10}§f{handler.GetServerTPS():F1}");
|
||||
|
||||
|
|
|
|||
|
|
@ -154,6 +154,11 @@ namespace MinecraftClient
|
|||
public string GetUserUuidStr() { return uuidStr; }
|
||||
public string GetSessionID() { return sessionid; }
|
||||
public Location GetCurrentLocation() { return location; }
|
||||
public bool GetLocalOnGround()
|
||||
{
|
||||
Location current = GetCurrentLocation();
|
||||
return physicsInitialized ? playerPhysics.OnGround : Movement.IsOnGround(world, current);
|
||||
}
|
||||
public float GetYaw() { return playerYaw; }
|
||||
public int GetSequenceId() { return sequenceId; }
|
||||
public float GetPitch() { return playerPitch; }
|
||||
|
|
|
|||
|
|
@ -69,34 +69,31 @@ namespace MinecraftClient.Pathing.Core
|
|||
foreach (int dz in offsets)
|
||||
moves.Add(new MoveSprintDescend(0, dz * 2));
|
||||
|
||||
// Cardinal parkour: 2-4 block sprint jumps along +-X and +-Z
|
||||
// Cardinal parkour: long sprint jumps along +-X and +-Z.
|
||||
// Longer distances remain gated by MoveParkour feasibility and available runway/carry.
|
||||
foreach (int dx in offsets)
|
||||
{
|
||||
for (int dist = 2; dist <= 4; dist++)
|
||||
for (int dist = 2; dist <= 5; dist++)
|
||||
moves.Add(new MoveParkour(dx * dist, 0));
|
||||
// Ascending: +1Y, dist 2-3 (dist 4 ascend not physically reliable)
|
||||
// Ascending cardinal parkour tops out at offset 3.
|
||||
for (int dist = 2; dist <= 3; dist++)
|
||||
moves.Add(new MoveParkour(dx * dist, 0, yDelta: 1));
|
||||
// Descending parkour: sprint-jump, land 1-2 blocks lower
|
||||
for (int dist = 2; dist <= 4; dist++)
|
||||
{
|
||||
// Descending cardinal parkour tops out at offset 5.
|
||||
for (int dist = 2; dist <= 5; dist++)
|
||||
moves.Add(new MoveParkour(dx * dist, 0, yDelta: -1));
|
||||
if (dist <= 3)
|
||||
moves.Add(new MoveParkour(dx * dist, 0, yDelta: -2));
|
||||
}
|
||||
for (int dist = 2; dist <= 5; dist++)
|
||||
moves.Add(new MoveParkour(dx * dist, 0, yDelta: -2));
|
||||
}
|
||||
foreach (int dz in offsets)
|
||||
{
|
||||
for (int dist = 2; dist <= 4; dist++)
|
||||
for (int dist = 2; dist <= 5; dist++)
|
||||
moves.Add(new MoveParkour(0, dz * dist));
|
||||
for (int dist = 2; dist <= 3; dist++)
|
||||
moves.Add(new MoveParkour(0, dz * dist, yDelta: 1));
|
||||
for (int dist = 2; dist <= 4; dist++)
|
||||
{
|
||||
for (int dist = 2; dist <= 5; dist++)
|
||||
moves.Add(new MoveParkour(0, dz * dist, yDelta: -1));
|
||||
if (dist <= 3)
|
||||
moves.Add(new MoveParkour(0, dz * dist, yDelta: -2));
|
||||
}
|
||||
for (int dist = 2; dist <= 5; dist++)
|
||||
moves.Add(new MoveParkour(0, dz * dist, yDelta: -2));
|
||||
}
|
||||
|
||||
// Diagonal parkour: sprint jumps at angles.
|
||||
|
|
@ -162,6 +159,7 @@ namespace MinecraftClient.Pathing.Core
|
|||
|
||||
int nodesExplored = 0;
|
||||
int unloadedChunkHits = 0;
|
||||
bool searchAborted = false;
|
||||
PathNode? bestPartialNode = startNode;
|
||||
double bestPartialScore = startNode.HCost + startNode.GCost * 0.5;
|
||||
MoveResult moveResult = default;
|
||||
|
|
@ -172,12 +170,14 @@ namespace MinecraftClient.Pathing.Core
|
|||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
searchAborted = true;
|
||||
DebugLog?.Invoke($"[A*] Cancelled after {nodesExplored} nodes, {sw.ElapsedMilliseconds}ms");
|
||||
break;
|
||||
}
|
||||
|
||||
if (sw.ElapsedMilliseconds > timeoutMs)
|
||||
{
|
||||
searchAborted = true;
|
||||
DebugLog?.Invoke($"[A*] Timeout ({timeoutMs}ms) after {nodesExplored} nodes");
|
||||
break;
|
||||
}
|
||||
|
|
@ -195,6 +195,7 @@ namespace MinecraftClient.Pathing.Core
|
|||
|
||||
foreach (var move in _allMoves)
|
||||
{
|
||||
ctx.PreviousMoveType = current.MoveUsed;
|
||||
moveResult.Cost = 0;
|
||||
move.Calculate(ctx, current.X, current.Y, current.Z, ref moveResult);
|
||||
|
||||
|
|
@ -251,7 +252,9 @@ namespace MinecraftClient.Pathing.Core
|
|||
}
|
||||
}
|
||||
|
||||
if (bestPartialNode is not null && bestPartialNode != startNode)
|
||||
if (bestPartialNode is not null
|
||||
&& bestPartialNode != startNode
|
||||
&& (searchAborted || unloadedChunkHits > 0))
|
||||
{
|
||||
DebugLog?.Invoke($"[A*] Partial path to ({bestPartialNode.X},{bestPartialNode.Y},{bestPartialNode.Z}), " +
|
||||
$"{nodesExplored} nodes, {sw.ElapsedMilliseconds}ms");
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ namespace MinecraftClient.Pathing.Core
|
|||
public double WalkCost { get; }
|
||||
public double SprintCost { get; }
|
||||
public double SneakCost { get; }
|
||||
public MoveType PreviousMoveType { get; internal set; }
|
||||
|
||||
public CalculationContext(
|
||||
World world,
|
||||
|
|
|
|||
|
|
@ -52,39 +52,57 @@ namespace MinecraftClient.Pathing.Execution
|
|||
return PathExecutorState.Complete;
|
||||
}
|
||||
|
||||
_segmentTicks++;
|
||||
_totalTicks++;
|
||||
var state = _currentTemplate.Tick(pos, physics, input, world);
|
||||
|
||||
switch (state)
|
||||
int sameTickAdvanceCount = 0;
|
||||
while (_currentTemplate is not null)
|
||||
{
|
||||
case TemplateState.Complete:
|
||||
input.Reset();
|
||||
_observer?.OnSegmentCompleted(_currentIndex, _segments.Count, _segments[_currentIndex], _segmentTicks, pos);
|
||||
_debugLog?.Invoke($"[PathExec] Segment {_currentIndex} complete " +
|
||||
$"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2})");
|
||||
_currentIndex++;
|
||||
_segmentTicks = 0;
|
||||
if (_currentIndex >= _segments.Count)
|
||||
{
|
||||
_currentTemplate = null;
|
||||
_debugLog?.Invoke("[PathExec] All segments complete!");
|
||||
return PathExecutorState.Complete;
|
||||
}
|
||||
AdvanceToNextSegment();
|
||||
return PathExecutorState.InProgress;
|
||||
_segmentTicks++;
|
||||
var state = _currentTemplate.Tick(pos, physics, input, world);
|
||||
|
||||
case TemplateState.Failed:
|
||||
input.Reset();
|
||||
_observer?.OnSegmentFailed(_currentIndex, _segments.Count, _segments[_currentIndex], _segmentTicks, pos);
|
||||
_debugLog?.Invoke($"[PathExec] Segment {_currentIndex} FAILED " +
|
||||
$"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2}), " +
|
||||
$"target was ({_currentTemplate.ExpectedEnd.X:F2},{_currentTemplate.ExpectedEnd.Y:F2},{_currentTemplate.ExpectedEnd.Z:F2})");
|
||||
return PathExecutorState.Failed;
|
||||
switch (state)
|
||||
{
|
||||
case TemplateState.Complete:
|
||||
_observer?.OnSegmentCompleted(_currentIndex, _segments.Count, _segments[_currentIndex], _segmentTicks, pos);
|
||||
_debugLog?.Invoke($"[PathExec] Segment {_currentIndex} complete " +
|
||||
$"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2})");
|
||||
_currentIndex++;
|
||||
_segmentTicks = 0;
|
||||
if (_currentIndex >= _segments.Count)
|
||||
{
|
||||
input.Reset();
|
||||
_currentTemplate = null;
|
||||
_debugLog?.Invoke("[PathExec] All segments complete!");
|
||||
return PathExecutorState.Complete;
|
||||
}
|
||||
|
||||
default:
|
||||
return PathExecutorState.InProgress;
|
||||
AdvanceToNextSegment();
|
||||
|
||||
// Do not waste the handoff tick when the next segment needs to issue
|
||||
// a jump or braking input immediately.
|
||||
sameTickAdvanceCount++;
|
||||
if (sameTickAdvanceCount > _segments.Count)
|
||||
{
|
||||
input.Reset();
|
||||
_debugLog?.Invoke("[PathExec] Excessive same-tick segment advances; aborting.");
|
||||
return PathExecutorState.Failed;
|
||||
}
|
||||
continue;
|
||||
|
||||
case TemplateState.Failed:
|
||||
input.Reset();
|
||||
_observer?.OnSegmentFailed(_currentIndex, _segments.Count, _segments[_currentIndex], _segmentTicks, pos);
|
||||
_debugLog?.Invoke($"[PathExec] Segment {_currentIndex} FAILED " +
|
||||
$"({_segments[_currentIndex].MoveType}) at ({pos.X:F2},{pos.Y:F2},{pos.Z:F2}), " +
|
||||
$"target was ({_currentTemplate.ExpectedEnd.X:F2},{_currentTemplate.ExpectedEnd.Y:F2},{_currentTemplate.ExpectedEnd.Z:F2})");
|
||||
return PathExecutorState.Failed;
|
||||
|
||||
default:
|
||||
return PathExecutorState.InProgress;
|
||||
}
|
||||
}
|
||||
|
||||
input.Reset();
|
||||
return PathExecutorState.Complete;
|
||||
}
|
||||
|
||||
private void AdvanceToNextSegment()
|
||||
|
|
|
|||
|
|
@ -38,6 +38,14 @@ namespace MinecraftClient.Pathing.Execution
|
|||
{
|
||||
_goal = goal;
|
||||
_replanCount = 0;
|
||||
if (result.Status == PathStatus.Failed || result.Path.Count < 2)
|
||||
{
|
||||
_infoLog?.Invoke("[PathMgr] Navigation rejected -- no path found.");
|
||||
_executor = null;
|
||||
_goal = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var segments = PathSegmentBuilder.FromPath(result.Path);
|
||||
_executor = new PathExecutor(segments, _debugLog, _observer);
|
||||
_infoLog?.Invoke($"[PathMgr] Navigation started: {segments.Count} segments");
|
||||
|
|
@ -53,6 +61,19 @@ namespace MinecraftClient.Pathing.Execution
|
|||
switch (state)
|
||||
{
|
||||
case PathExecutorState.Complete:
|
||||
if (_goal is not null)
|
||||
{
|
||||
int px = (int)Math.Floor(pos.X);
|
||||
int py = (int)Math.Floor(pos.Y);
|
||||
int pz = (int)Math.Floor(pos.Z);
|
||||
if (!_goal.IsInGoal(px, py, pz))
|
||||
{
|
||||
_infoLog?.Invoke("[PathMgr] Planned route ended before reaching goal, replanning...");
|
||||
Replan(pos, world);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_observer?.OnNavigationCompleted(_executor.TotalTicks);
|
||||
_infoLog?.Invoke("[PathMgr] Navigation complete!");
|
||||
_executor = null;
|
||||
|
|
|
|||
|
|
@ -67,7 +67,11 @@ namespace MinecraftClient.Pathing.Execution.Templates
|
|||
TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world);
|
||||
if (horizDistSq > 0.01 && !decision.HoldBack)
|
||||
{
|
||||
float groundedYaw = TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment)
|
||||
bool onOrPastTarget = TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd)
|
||||
|| TemplateHelper.HasReachedSegmentEndPlane(pos, _segment);
|
||||
float groundedYaw = onOrPastTarget
|
||||
? TemplateHelper.GetExitHeadingYaw(_segment)
|
||||
: TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment)
|
||||
? TemplateHelper.GetExitHeadingYaw(_segment)
|
||||
: targetYaw;
|
||||
physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, groundedYaw);
|
||||
|
|
@ -90,8 +94,16 @@ namespace MinecraftClient.Pathing.Execution.Templates
|
|||
}
|
||||
else if (horizDistSq > 0.01)
|
||||
{
|
||||
physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw);
|
||||
if (_hasFallen || YawDifference(physics.Yaw, targetYaw) <= PreDropYawToleranceDeg)
|
||||
bool onOrPastTarget = TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd)
|
||||
|| TemplateHelper.HasReachedSegmentEndPlane(pos, _segment);
|
||||
bool biasTowardExitInAir = onOrPastTarget
|
||||
|| (_hasFallen && TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment, distanceThreshold: 1.5));
|
||||
float airborneYaw = biasTowardExitInAir
|
||||
? TemplateHelper.GetExitHeadingYaw(_segment)
|
||||
: targetYaw;
|
||||
|
||||
physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, airborneYaw);
|
||||
if (_hasFallen || YawDifference(physics.Yaw, airborneYaw) <= PreDropYawToleranceDeg)
|
||||
{
|
||||
if (!_hasFallen && !_needsSprint && ShouldCoastOffLedge(pos))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using MinecraftClient.Mapping;
|
||||
using MinecraftClient.Pathing.Core;
|
||||
using MinecraftClient.Physics;
|
||||
|
||||
namespace MinecraftClient.Pathing.Execution.Templates
|
||||
|
|
@ -31,6 +32,7 @@ namespace MinecraftClient.Pathing.Execution.Templates
|
|||
private Phase _phase = Phase.Approach;
|
||||
private bool _leftGround;
|
||||
private bool _carriedGroundEntry;
|
||||
private bool _releaseForwardLatched;
|
||||
|
||||
private const float YawToleranceDeg = 5f;
|
||||
|
||||
|
|
@ -53,10 +55,19 @@ namespace MinecraftClient.Pathing.Execution.Templates
|
|||
double dz = ExpectedEnd.Z - pos.Z;
|
||||
double dy = ExpectedEnd.Y - pos.Y;
|
||||
double horizDistSq = dx * dx + dz * dz;
|
||||
bool prepareJumpTouchdown = _phase == Phase.Airborne && _leftGround && physics.OnGround;
|
||||
bool groundedPrepareJumpHandoff = (_phase == Phase.Landing || prepareJumpTouchdown)
|
||||
&& physics.OnGround
|
||||
&& _segment.ExitTransition == PathTransitionType.PrepareJump
|
||||
&& _segment.ExitHints.RequireJumpReady
|
||||
&& (TemplateFootingHelper.IsCenterInsideTargetBlock(pos, _segment.End)
|
||||
|| TemplateHelper.HasReachedSegmentEndPlane(pos, _segment));
|
||||
|
||||
float targetYaw = TemplateHelper.CalculateYaw(dx, dz);
|
||||
float targetPitch = TemplateHelper.CalculatePitch(dx, dy, dz);
|
||||
physics.Yaw = TemplateHelper.SmoothYaw(physics.Yaw, targetYaw);
|
||||
physics.Yaw = groundedPrepareJumpHandoff
|
||||
? TemplateHelper.SmoothYaw(physics.Yaw, TemplateHelper.GetExitHeadingYaw(_segment))
|
||||
: TemplateHelper.SmoothYaw(physics.Yaw, targetYaw);
|
||||
physics.Pitch = TemplateHelper.SmoothPitch(physics.Pitch, targetPitch);
|
||||
|
||||
switch (_phase)
|
||||
|
|
@ -67,38 +78,49 @@ namespace MinecraftClient.Pathing.Execution.Templates
|
|||
if (_tickCount == 1 && TemplateHelper.GetHorizontalSpeed(physics) > 0.02)
|
||||
_carriedGroundEntry = true;
|
||||
|
||||
double fromStartSq = TemplateHelper.HorizontalDistanceSq(pos, ExpectedStart);
|
||||
double approachProgress = ((pos.X - ExpectedStart.X) * _segment.HeadingX)
|
||||
+ ((pos.Z - ExpectedStart.Z) * _segment.HeadingZ);
|
||||
float yawDelta = YawDifference(physics.Yaw, targetYaw);
|
||||
bool turnInPlace = yawDelta > 35f;
|
||||
input.Forward = !turnInPlace;
|
||||
input.Sprint = !turnInPlace;
|
||||
|
||||
bool carriedShortFinalStopJump = _carriedGroundEntry
|
||||
&& _segment.ExitTransition == PathTransitionType.FinalStop
|
||||
&& _horizDist <= 2.5;
|
||||
bool carriedDescendingFinalStopJump = _carriedGroundEntry
|
||||
&& _segment.ExitTransition == PathTransitionType.FinalStop
|
||||
&& ExpectedEnd.Y < ExpectedStart.Y
|
||||
&& _horizDist <= 3.5;
|
||||
bool carriedDescendingParkourJump = _carriedGroundEntry
|
||||
&& _segment.ExitTransition == PathTransitionType.PrepareJump
|
||||
&& ExpectedEnd.Y < ExpectedStart.Y
|
||||
&& _horizDist <= 3.5;
|
||||
if (carriedShortFinalStopJump || carriedDescendingFinalStopJump || carriedDescendingParkourJump)
|
||||
input.Sprint = false;
|
||||
|
||||
// Build momentum before jumping. Sprint speed is ~5.6 m/s
|
||||
// (0.28 blocks/tick). More run-up = more airtime distance.
|
||||
// Standing sprint jump (0t): ~3.6 blocks horizontal
|
||||
// 2-tick sprint (0.56m): ~4.3 blocks horizontal
|
||||
// 4-tick sprint (1.1m): ~5.0 blocks horizontal
|
||||
double minApproachSq;
|
||||
if (_horizDist >= 5.0)
|
||||
minApproachSq = 0.64; // 0.8 blocks - 3+ ticks of sprint
|
||||
double minApproachDistance;
|
||||
bool carriedLongDescendingJump = _carriedGroundEntry
|
||||
&& ExpectedEnd.Y < ExpectedStart.Y
|
||||
&& _horizDist >= 5.0;
|
||||
if (carriedLongDescendingJump)
|
||||
minApproachDistance = 0.8; // use nearly the full landing block to preserve long-jump carry
|
||||
else if (_horizDist >= 5.0)
|
||||
minApproachDistance = 0.8; // 3+ ticks of sprint
|
||||
else if (_horizDist >= 4.0)
|
||||
minApproachSq = 0.36; // 0.6 blocks - 2-3 ticks of sprint
|
||||
minApproachDistance = 0.6; // 2-3 ticks of sprint
|
||||
else if (_horizDist > 3.5)
|
||||
minApproachSq = 0.09; // 0.3 blocks - 1-2 ticks of sprint
|
||||
minApproachDistance = 0.3; // 1-2 ticks of sprint
|
||||
else
|
||||
minApproachSq = 0.0;
|
||||
|
||||
if (_carriedGroundEntry
|
||||
&& _segment.ExitTransition == PathTransitionType.FinalStop
|
||||
&& _horizDist <= 2.5
|
||||
&& GetLateralOffsetFromSegmentLine(pos) > 0.20)
|
||||
{
|
||||
input.Sprint = false;
|
||||
}
|
||||
minApproachDistance = 0.0;
|
||||
|
||||
bool yawAligned = yawDelta < YawToleranceDeg;
|
||||
bool posReady = fromStartSq >= minApproachSq;
|
||||
|
||||
bool posReady = approachProgress >= minApproachDistance;
|
||||
if (yawAligned && posReady)
|
||||
{
|
||||
input.Jump = true;
|
||||
|
|
@ -122,20 +144,25 @@ namespace MinecraftClient.Pathing.Execution.Templates
|
|||
_leftGround = true;
|
||||
|
||||
bool pastTarget = IsPastTarget(pos);
|
||||
bool parkourOnOrPastTarget = _segment.MoveType == MoveType.Parkour
|
||||
&& (TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, ExpectedEnd)
|
||||
|| TemplateHelper.HasReachedSegmentEndPlane(pos, _segment)
|
||||
|| pastTarget);
|
||||
bool biasTowardExitInAir = _segment.ExitTransition == PathTransitionType.LandingRecovery
|
||||
? TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment, distanceThreshold: 1.5)
|
||||
: TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment);
|
||||
if (biasTowardExitInAir)
|
||||
if (parkourOnOrPastTarget || biasTowardExitInAir)
|
||||
TemplateHelper.FaceExitHeading(physics, _segment);
|
||||
|
||||
bool lookaheadAirBrake = TransitionBrakingPlanner.ShouldReleaseForwardInAir(
|
||||
_segment, _nextSegment, pos, physics, world);
|
||||
bool releaseInAir = ShouldReleaseInAir(pos, physics, world);
|
||||
_releaseForwardLatched |= releaseInAir;
|
||||
bool earlySoftBrake = _segment.ExitTransition == PathTransitionType.LandingRecovery
|
||||
&& lookaheadAirBrake
|
||||
&& !releaseInAir;
|
||||
|
||||
if (releaseInAir || pastTarget)
|
||||
if (_releaseForwardLatched || pastTarget)
|
||||
{
|
||||
input.Forward = false;
|
||||
input.Sprint = false;
|
||||
|
|
@ -148,7 +175,8 @@ namespace MinecraftClient.Pathing.Execution.Templates
|
|||
else
|
||||
{
|
||||
input.Forward = true;
|
||||
input.Sprint = true;
|
||||
input.Sprint = !(_segment.ExitTransition == PathTransitionType.FinalStop
|
||||
&& ExpectedEnd.Y < ExpectedStart.Y);
|
||||
}
|
||||
|
||||
if (_leftGround && physics.OnGround)
|
||||
|
|
@ -160,15 +188,36 @@ namespace MinecraftClient.Pathing.Execution.Templates
|
|||
}
|
||||
|
||||
case Phase.Landing:
|
||||
TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world);
|
||||
TemplateHelper.ApplyDecision(input, decision);
|
||||
if (decision.HoldBack)
|
||||
TemplateHelper.FaceSegmentHeading(physics, _segment);
|
||||
else if (TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment))
|
||||
bool descendingPrepareJump = _segment.ExitTransition == PathTransitionType.PrepareJump
|
||||
&& ExpectedEnd.Y < ExpectedStart.Y;
|
||||
bool descendingPrepareJumpOnSupport = descendingPrepareJump
|
||||
&& physics.OnGround
|
||||
&& TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, _segment.End);
|
||||
bool descendingPrepareJumpPastSupport = descendingPrepareJump
|
||||
&& physics.OnGround
|
||||
&& TemplateHelper.HasReachedSegmentEndPlane(pos, _segment)
|
||||
&& !descendingPrepareJumpOnSupport;
|
||||
|
||||
if (descendingPrepareJumpPastSupport)
|
||||
{
|
||||
input.Forward = false;
|
||||
input.Sprint = false;
|
||||
input.Back = true;
|
||||
TemplateHelper.FaceExitHeading(physics, _segment);
|
||||
}
|
||||
else
|
||||
{
|
||||
TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(_segment, _nextSegment, pos, physics, world);
|
||||
TemplateHelper.ApplyDecision(input, decision);
|
||||
if (decision.HoldBack)
|
||||
TemplateHelper.FaceSegmentHeading(physics, _segment);
|
||||
else if (TemplateHelper.ShouldBiasTowardExitHeading(pos, _segment))
|
||||
TemplateHelper.FaceExitHeading(physics, _segment);
|
||||
}
|
||||
|
||||
if (_segment.ExitTransition == PathTransitionType.PrepareJump
|
||||
&& physics.OnGround
|
||||
&& (!descendingPrepareJump || descendingPrepareJumpOnSupport)
|
||||
&& GroundedSegmentController.ShouldComplete(_segment, pos, physics))
|
||||
{
|
||||
return TemplateState.Complete;
|
||||
|
|
@ -236,6 +285,17 @@ namespace MinecraftClient.Pathing.Execution.Templates
|
|||
if (_segment.ExitTransition == PathTransitionType.ContinueStraight || physics.OnGround)
|
||||
return false;
|
||||
|
||||
bool heuristicFinalStopRelease = _segment.ExitTransition == PathTransitionType.FinalStop
|
||||
&& ShouldReleaseByRemainingLead(pos, physics);
|
||||
if (heuristicFinalStopRelease)
|
||||
return true;
|
||||
|
||||
bool heuristicDescendingPrepareJumpRelease = _segment.ExitTransition == PathTransitionType.PrepareJump
|
||||
&& ExpectedEnd.Y < ExpectedStart.Y
|
||||
&& ShouldReleaseByRemainingLead(pos, physics);
|
||||
if (heuristicDescendingPrepareJumpRelease)
|
||||
return true;
|
||||
|
||||
bool plannerWantsRelease = TransitionBrakingPlanner.ShouldReleaseForwardInAir(
|
||||
_segment, _nextSegment, pos, physics, world);
|
||||
double remaining = TemplateHelper.RemainingDistanceAlongSegment(pos, _segment);
|
||||
|
|
@ -259,6 +319,16 @@ namespace MinecraftClient.Pathing.Execution.Templates
|
|||
return !holdingStaysInside && releasingStaysInside;
|
||||
}
|
||||
|
||||
private bool ShouldReleaseByRemainingLead(Location pos, PlayerPhysics physics)
|
||||
{
|
||||
double remaining = TemplateHelper.RemainingDistanceAlongSegment(pos, _segment);
|
||||
double forwardSpeed = Math.Max(0.0,
|
||||
TemplateHelper.ProjectHorizontalSpeedAlongHeading(physics, _segment.HeadingX, _segment.HeadingZ));
|
||||
double dropHeight = Math.Max(0.0, ExpectedStart.Y - ExpectedEnd.Y);
|
||||
double releaseLead = 0.14 + (Math.Max(0.0, dropHeight - 1.0) * 0.20);
|
||||
return remaining <= forwardSpeed + releaseLead;
|
||||
}
|
||||
|
||||
private Location? PredictLandingPosition(PlayerPhysics physics, World world, bool holdForward, bool holdSprint)
|
||||
{
|
||||
PlayerPhysics sim = TemplateHelper.ClonePhysicsForPlanning(physics);
|
||||
|
|
|
|||
|
|
@ -137,6 +137,17 @@ namespace MinecraftClient.Pathing.Execution.Templates
|
|||
return dx * segment.HeadingX + dz * segment.HeadingZ;
|
||||
}
|
||||
|
||||
internal static double LateralOffsetFromSegmentLine(Location pos, PathSegment segment)
|
||||
{
|
||||
GetNormalizedSegmentDirection(segment, out double dirX, out double dirZ);
|
||||
if (dirX == 0.0 && dirZ == 0.0)
|
||||
return 0.0;
|
||||
|
||||
double relX = pos.X - segment.Start.X;
|
||||
double relZ = pos.Z - segment.Start.Z;
|
||||
return Math.Abs((-dirZ * relX) + (dirX * relZ));
|
||||
}
|
||||
|
||||
internal static bool ShouldBiasTowardExitHeading(Location pos, PathSegment segment, double distanceThreshold = 0.35)
|
||||
{
|
||||
GetExitHeading(segment, out int headingX, out int headingZ);
|
||||
|
|
|
|||
|
|
@ -58,6 +58,24 @@ namespace MinecraftClient.Pathing.Moves.Impl
|
|||
return;
|
||||
}
|
||||
|
||||
bool cardinal = (XOffset == 0) != (ZOffset == 0);
|
||||
if (cardinal)
|
||||
{
|
||||
int distance = Math.Max(Math.Abs(XOffset), Math.Abs(ZOffset));
|
||||
int maxDistance = _yDelta switch
|
||||
{
|
||||
> 0 => 3,
|
||||
< 0 => 5,
|
||||
_ => 5,
|
||||
};
|
||||
|
||||
if (distance > maxDistance)
|
||||
{
|
||||
result.SetImpossible();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't parkour from climbable blocks (unreliable jump)
|
||||
Material standingOn = ctx.GetMaterial(x, y - 1, z);
|
||||
if (standingOn.CanBeClimbedOn())
|
||||
|
|
@ -105,6 +123,12 @@ namespace MinecraftClient.Pathing.Moves.Impl
|
|||
return;
|
||||
}
|
||||
|
||||
if (ParkourFeasibility.HasIntermediateLandingConflict(ctx, x, y, z, XOffset, ZOffset, _yDelta))
|
||||
{
|
||||
result.SetImpossible();
|
||||
return;
|
||||
}
|
||||
|
||||
int xSign = Math.Sign(XOffset);
|
||||
int zSign = Math.Sign(ZOffset);
|
||||
int xAbs = Math.Abs(XOffset);
|
||||
|
|
|
|||
|
|
@ -15,10 +15,22 @@ internal static class ParkourFeasibility
|
|||
int yDelta)
|
||||
{
|
||||
double horiz = Math.Sqrt(xOffset * xOffset + zOffset * zOffset);
|
||||
double threshold = yDelta > 0 ? 2.5 : 3.5;
|
||||
bool carriedEntry = ctx.PreviousMoveType is MoveType.Parkour or MoveType.Descend;
|
||||
double threshold = yDelta switch
|
||||
{
|
||||
> 0 when carriedEntry => 4.5,
|
||||
> 0 => 2.5,
|
||||
< 0 when carriedEntry => 5.5,
|
||||
< 0 => 3.5,
|
||||
_ when carriedEntry => 5.5,
|
||||
_ => 3.5,
|
||||
};
|
||||
if (horiz < threshold)
|
||||
return true;
|
||||
|
||||
if (carriedEntry && yDelta < 0)
|
||||
return true;
|
||||
|
||||
int backX = x - Math.Sign(xOffset);
|
||||
int backZ = z - Math.Sign(zOffset);
|
||||
if (!ctx.CanWalkOn(backX, y - 1, backZ))
|
||||
|
|
@ -96,6 +108,42 @@ internal static class ParkourFeasibility
|
|||
return true;
|
||||
}
|
||||
|
||||
public static bool HasIntermediateLandingConflict(
|
||||
CalculationContext ctx,
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
int xOffset,
|
||||
int zOffset,
|
||||
int yDelta)
|
||||
{
|
||||
if (yDelta >= 0)
|
||||
return false;
|
||||
|
||||
bool cardinal = (xOffset == 0) != (zOffset == 0);
|
||||
int distance = Math.Max(Math.Abs(xOffset), Math.Abs(zOffset));
|
||||
if (!cardinal || distance < 6)
|
||||
return false;
|
||||
|
||||
int destY = y + yDelta;
|
||||
int xSign = Math.Sign(xOffset);
|
||||
int zSign = Math.Sign(zOffset);
|
||||
|
||||
for (int step = 1; step < distance; step++)
|
||||
{
|
||||
int gx = x + (xOffset != 0 ? xSign * step : 0);
|
||||
int gz = z + (zOffset != 0 ? zSign * step : 0);
|
||||
|
||||
for (int candidateY = y - 1; candidateY >= destY; candidateY--)
|
||||
{
|
||||
if (ctx.CanWalkOn(gx, candidateY - 1, gz) && IsColumnPassable(ctx, gx, candidateY, gz))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsColumnPassable(CalculationContext ctx, int x, int y, int z)
|
||||
{
|
||||
return ctx.CanWalkThrough(x, y, z)
|
||||
|
|
|
|||
|
|
@ -529,16 +529,23 @@ namespace MinecraftClient.Physics
|
|||
/// </summary>
|
||||
private float GetFrictionInfluencedSpeed(float friction)
|
||||
{
|
||||
float effectiveSpeed = GetEffectiveMovementSpeed();
|
||||
if (OnGround)
|
||||
{
|
||||
return MovementSpeed * (PhysicsConsts.GroundAccelerationFactor / (friction * friction * friction));
|
||||
return effectiveSpeed * (PhysicsConsts.GroundAccelerationFactor / (friction * friction * friction));
|
||||
}
|
||||
else
|
||||
{
|
||||
return CreativeFlying ? MovementSpeed * 0.1f : PhysicsConsts.AirAcceleration;
|
||||
return CreativeFlying ? effectiveSpeed * 0.1f : effectiveSpeed * 0.2f;
|
||||
}
|
||||
}
|
||||
|
||||
private float GetEffectiveMovementSpeed()
|
||||
{
|
||||
// Vanilla applies a transient +30% total movement-speed modifier while sprinting.
|
||||
return Sprinting ? MovementSpeed * 1.3f : MovementSpeed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the friction of the block below the player's feet.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -6788,6 +6788,15 @@ namespace MinecraftClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to OnGround.
|
||||
/// </summary>
|
||||
internal static string cmd_debug_state_on_ground {
|
||||
get {
|
||||
return ResourceManager.GetString("cmd.debug.state_on_ground", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to TPS.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -2394,6 +2394,9 @@ Logging in...</value>
|
|||
<data name="cmd.debug.state_location" xml:space="preserve">
|
||||
<value>Location</value>
|
||||
</data>
|
||||
<data name="cmd.debug.state_on_ground" xml:space="preserve">
|
||||
<value>OnGround</value>
|
||||
</data>
|
||||
<data name="cmd.debug.state_tps" xml:space="preserve">
|
||||
<value>TPS</value>
|
||||
</data>
|
||||
|
|
|
|||
618
docs/superpowers/plans/2026-04-16-linear-parallel-zero-replan.md
Normal file
618
docs/superpowers/plans/2026-04-16-linear-parallel-zero-replan.md
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
# Linear Parallel Zero-Replan Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the `tools/test-parkour.py --filter linear --parallel 6` run on `1.21.11-Vanilla` execute all theory-allowed `linear` cases as `pass`, all theory-forbidden cases as `reject`, and detect any `replan` or turn-in-place stall as a test failure.
|
||||
|
||||
**Architecture:** Treat the current problem as two coupled systems. First, harden the live harness so parallel runs are trustworthy: worker startup must be deterministic, outcome classification must parse `[PathMetric]` telemetry, and the runner must detect turn-in-place stalls instead of inferring success from `Navigation complete!` plus a loose proximity check. Second, add focused C# regressions for the four currently exposed linear failures, then tune the parkour execution templates and grounded completion thresholds until those scenarios complete without replans, overshoot, or stall behavior.
|
||||
|
||||
**Tech Stack:** Python 3, pytest/unittest, C# 14 /.NET 10, MCC pathing runtime, local `1.21.11-Vanilla` server harness, RCON, tmux-backed `mcc-debug`.
|
||||
|
||||
---
|
||||
|
||||
## Current Facts
|
||||
|
||||
- Latest valid executed parallel live run used:
|
||||
- `python3 tools/test-parkour.py --filter linear --parallel 6 --version 1.21.11-Vanilla --results /tmp/linear-live-valid-20260416.jsonl`
|
||||
- That run built all 25 courses, launched 6 workers, and executed 13 cases before group-level stop-at-first-failure logic skipped the rest of each failing group.
|
||||
- Current observed live mismatches are:
|
||||
- `linear-flat-gap1`: expected `pass`, got `fail`
|
||||
- `linear-ascend-gap2-dy+1`: expected `pass`, got `fail`
|
||||
- `linear-descend-gap2-dy-2`: expected `pass`, got `fail`
|
||||
- `linear-descend-gap4-dy-1`: expected `pass`, got `fail`
|
||||
- Current harness behavior is now trustworthy for this task:
|
||||
- parses `[PathMetric]` telemetry
|
||||
- fails any pass-case with `replan_count > 0`
|
||||
- fails any pass-case with turn-stall detection
|
||||
- persists `replan_count`, `turn_stall_count`, `near_goal`, `total_ticks`, and `final_position` to JSONL
|
||||
- Current C# regression status is narrower than live status:
|
||||
- targeted `SprintJumpTemplate` regressions were added and used to drive several execution fixes
|
||||
- local C# red lights no longer fully predict the live failure surface
|
||||
- the next TDD cycle must add regression coverage for `linear-flat-gap1`, `linear-ascend-gap2-dy+1`, and `linear-descend-gap4-dy-1`, not just the original four
|
||||
- The parallel worker startup bug in `.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh` remains fixed and covered by tests.
|
||||
- Local references are available if needed:
|
||||
- Decompiled server source under `$MCC_REPO/MinecraftOfficial/<version>-decompiled/`
|
||||
- `ThirdpartyReference/baritone`
|
||||
|
||||
## File Structure
|
||||
|
||||
### Harness / integration loop
|
||||
|
||||
- Modify: `.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh`
|
||||
- Fix ambiguous argument parsing when the output ini already exists.
|
||||
- Modify: `tools/test-parkour.py`
|
||||
- Add strict live metrics parsing.
|
||||
- Add turn-in-place stall detection during navigation.
|
||||
- Persist replan/turn metrics into JSONL.
|
||||
- Keep parallel worker behavior, but make results trustworthy.
|
||||
- Modify: `tools/tests/test_pathing_live_scripts.py`
|
||||
- Keep startup/config regression coverage and align list-case assertions with the current script interface.
|
||||
- Create: `tools/tests/test_test_parkour_metrics.py`
|
||||
- Focused tests for log parsing, outcome classification, and turn-in-place detection.
|
||||
|
||||
### Runtime / movement fixes
|
||||
|
||||
- Create: `MinecraftClient.Tests/Pathing/Execution/Scenarios/LinearParkourScenarioBuilder.cs`
|
||||
- Shared helper to construct the same linear layouts the live harness uses.
|
||||
- Modify: `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs`
|
||||
- Planner/executor regressions that mirror the current valid live mismatches.
|
||||
- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs`
|
||||
- Fine-grained parkour landing / overshoot regressions, including currently live-failing flat/ascend/descend chains.
|
||||
- Modify: `MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs`
|
||||
- No-replan regressions for accepted linear chains and any newly observed live failures.
|
||||
- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs`
|
||||
- Air-brake and landing-completion behavior for long flat / falling jumps.
|
||||
- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs`
|
||||
- Final-stop and prepare-jump completion thresholds.
|
||||
- Modify: `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs`
|
||||
- Lookahead braking when the next expected state is a true stop.
|
||||
- Modify: `MinecraftClient/Pathing/Execution/TemplateHelper.cs`
|
||||
- Extract any shared “still moving too fast to count as settled” helpers used by the above.
|
||||
|
||||
## Task 1: Lock Down The Parallel Harness Contract
|
||||
|
||||
**Files:**
|
||||
- Modify: `.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh`
|
||||
- Modify: `tools/tests/test_pathing_live_scripts.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing config regression test**
|
||||
|
||||
Add this test to `tools/tests/test_pathing_live_scripts.py`:
|
||||
|
||||
```python
|
||||
def test_prepare_offline_config_treats_existing_output_ini_as_output_not_template(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
temp_path = Path(tempdir)
|
||||
output_ini = temp_path / "MinecraftClient.debug.ini"
|
||||
output_ini.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"[Main.General]",
|
||||
'Account = { Login = "OldBot", Password = "" }',
|
||||
'AccountType = "microsoft"',
|
||||
"",
|
||||
"[Main.Advanced]",
|
||||
'MinecraftVersion = "auto"',
|
||||
"TerrainAndMovements = false",
|
||||
"InventoryHandling = false",
|
||||
"EntityHandling = false",
|
||||
"AutoRespawn = false",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
str(REPO_ROOT / ".skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh"),
|
||||
str(output_ini),
|
||||
"1.21.11",
|
||||
"MCCBot1",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=temp_path,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse((temp_path / "1.21.11").exists())
|
||||
content = output_ini.read_text(encoding="utf-8")
|
||||
self.assertIn('AccountType = "mojang"', content)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m pytest -q tools/tests/test_pathing_live_scripts.py -k existing_output_ini_as_output_not_template
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
FAILED ... AssertionError: True is not false
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement the minimal parser fix**
|
||||
|
||||
Update `.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh`:
|
||||
|
||||
```bash
|
||||
if [[ $# -ge 3 && "$2" == *.ini ]]; then
|
||||
TEMPLATE_INI="$1"
|
||||
OUTPUT_INI="$2"
|
||||
MC_VERSION="$3"
|
||||
LOGIN_NAME="${4:-MCCBot}"
|
||||
else
|
||||
OUTPUT_INI="$1"
|
||||
MC_VERSION="$2"
|
||||
LOGIN_NAME="${3:-MCCBot}"
|
||||
fi
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the full live-script test file**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m pytest -q tools/tests/test_pathing_live_scripts.py
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
5 passed
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add .skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh \
|
||||
tools/tests/test_pathing_live_scripts.py
|
||||
git commit -m "test-harness: fix parallel worker config bootstrap"
|
||||
```
|
||||
|
||||
## Task 2: Make The Harness Enforce Zero Replan And Turn-Stall Failures
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/tests/test_test_parkour_metrics.py`
|
||||
- Modify: `tools/test-parkour.py`
|
||||
|
||||
- [ ] **Step 1: Write parser/classification tests first**
|
||||
|
||||
Create `tools/tests/test_test_parkour_metrics.py` with focused tests like:
|
||||
|
||||
```python
|
||||
def test_classify_outcome_pass_requires_route_complete_and_zero_replans():
|
||||
metrics = LiveMetrics(
|
||||
planner_status="Success",
|
||||
route_complete_count=1,
|
||||
navigation_complete_count=1,
|
||||
replan_count=0,
|
||||
turn_stall_count=0,
|
||||
)
|
||||
assert classify_outcome(metrics) == "pass"
|
||||
|
||||
|
||||
def test_classify_outcome_replan_is_fail():
|
||||
metrics = LiveMetrics(
|
||||
planner_status="Success",
|
||||
route_complete_count=1,
|
||||
navigation_complete_count=1,
|
||||
replan_count=1,
|
||||
turn_stall_count=0,
|
||||
)
|
||||
assert classify_outcome(metrics) == "fail"
|
||||
|
||||
|
||||
def test_classify_outcome_turn_stall_is_fail():
|
||||
metrics = LiveMetrics(
|
||||
planner_status="Success",
|
||||
route_complete_count=1,
|
||||
navigation_complete_count=1,
|
||||
replan_count=0,
|
||||
turn_stall_count=1,
|
||||
)
|
||||
assert classify_outcome(metrics) == "fail"
|
||||
|
||||
|
||||
def test_detect_turn_stall_requires_low_motion_and_large_yaw_change():
|
||||
samples = [
|
||||
NavigationSample(x=100.5, y=80.0, z=100.5, yaw=0.0),
|
||||
NavigationSample(x=100.6, y=80.0, z=100.5, yaw=95.0),
|
||||
NavigationSample(x=100.6, y=80.0, z=100.5, yaw=185.0),
|
||||
]
|
||||
assert detect_turn_stall(samples) is True
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the new tests to verify they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m pytest -q tools/tests/test_test_parkour_metrics.py
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
FAILED ... NameError / AttributeError for LiveMetrics, classify_outcome, NavigationSample, detect_turn_stall
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add strict live metrics and turn-stall sampling**
|
||||
|
||||
In `tools/test-parkour.py`, add the minimal structures and parsing helpers:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class NavigationSample:
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
yaw: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiveMetrics:
|
||||
planner_status: str | None = None
|
||||
route_complete_count: int = 0
|
||||
navigation_complete_count: int = 0
|
||||
replan_count: int = 0
|
||||
replan_failed_count: int = 0
|
||||
segment_failed_count: int = 0
|
||||
turn_stall_count: int = 0
|
||||
|
||||
|
||||
def classify_outcome(metrics: LiveMetrics) -> str:
|
||||
if metrics.replan_count or metrics.replan_failed_count or metrics.segment_failed_count or metrics.turn_stall_count:
|
||||
return "fail"
|
||||
if metrics.planner_status in {"Partial", "Failed"}:
|
||||
return "reject"
|
||||
if metrics.route_complete_count or metrics.navigation_complete_count:
|
||||
return "pass"
|
||||
return "invalid_live_case"
|
||||
```
|
||||
|
||||
Also add navigation polling that samples both position and rotation during `wait_seconds`:
|
||||
|
||||
```python
|
||||
def get_player_pose(rcon: RconClient, username: str) -> NavigationSample | None:
|
||||
pos = rcon.command(f"data get entity {username} Pos")
|
||||
rot = rcon.command(f"data get entity {username} Rotation")
|
||||
...
|
||||
return NavigationSample(x, y, z, yaw)
|
||||
```
|
||||
|
||||
```python
|
||||
def detect_turn_stall(samples: list[NavigationSample]) -> bool:
|
||||
if len(samples) < 3:
|
||||
return False
|
||||
total_motion = sum(math.dist((a.x, a.z), (b.x, b.z)) for a, b in zip(samples, samples[1:]))
|
||||
total_yaw = sum(abs(normalize_yaw_delta(b.yaw - a.yaw)) for a, b in zip(samples, samples[1:]))
|
||||
return total_motion < 1.0 and total_yaw >= 180.0
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Persist the new metrics into JSONL**
|
||||
|
||||
Extend the JSONL write in `tools/test-parkour.py`:
|
||||
|
||||
```python
|
||||
f.write(json.dumps({
|
||||
"case_id": case.case_id,
|
||||
"expected": case.expected,
|
||||
"outcome": result.outcome,
|
||||
"matched": result.matched_expected,
|
||||
"worker": worker_id,
|
||||
"planner_status": result.metrics.planner_status,
|
||||
"replan_count": result.metrics.replan_count,
|
||||
"replan_failed_count": result.metrics.replan_failed_count,
|
||||
"segment_failed_count": result.metrics.segment_failed_count,
|
||||
"turn_stall_count": result.metrics.turn_stall_count,
|
||||
}) + "\n")
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify the harness tests pass**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m pytest -q tools/tests/test_test_parkour_metrics.py
|
||||
python3 -m pytest -q tools/tests/test_pathing_live_scripts.py
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
all green
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tools/test-parkour.py \
|
||||
tools/tests/test_test_parkour_metrics.py \
|
||||
tools/tests/test_pathing_live_scripts.py
|
||||
git commit -m "test-harness: enforce zero-replan and turn-stall failures"
|
||||
```
|
||||
|
||||
## Task 3: Reproduce The Four Live Linear Failures In C# Tests
|
||||
|
||||
**Files:**
|
||||
- Create: `MinecraftClient.Tests/Pathing/Execution/Scenarios/LinearParkourScenarioBuilder.cs`
|
||||
- Modify: `MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs`
|
||||
- Modify: `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs`
|
||||
- Modify: `MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Add a shared linear world builder helper**
|
||||
|
||||
Create `MinecraftClient.Tests/Pathing/Execution/Scenarios/LinearParkourScenarioBuilder.cs`:
|
||||
|
||||
```csharp
|
||||
using MinecraftClient.Mapping;
|
||||
|
||||
namespace MinecraftClient.Tests.Pathing.Execution.Scenarios;
|
||||
|
||||
internal static class LinearParkourScenarioBuilder
|
||||
{
|
||||
internal static World Build(int gap, int deltaY, out Location start, out Location end)
|
||||
{
|
||||
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 90, max: 140);
|
||||
FlatWorldTestBuilder.ClearBox(world, 96, 70, 96, 132, 90, 104);
|
||||
|
||||
start = new Location(100.5, 80, 100.5);
|
||||
int floorY = 79;
|
||||
FlatWorldTestBuilder.FillSolid(world, 100, floorY, 100, 103, floorY, 100);
|
||||
|
||||
int lastX = 103;
|
||||
int lastY = floorY;
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
int platX = lastX + gap + 1;
|
||||
int platY = lastY + deltaY;
|
||||
FlatWorldTestBuilder.SetSolid(world, platX, platY, 100);
|
||||
lastX = platX;
|
||||
lastY = platY;
|
||||
}
|
||||
|
||||
end = new Location(lastX + 0.5, lastY + 1, 100.5);
|
||||
return world;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing manager-level regressions**
|
||||
|
||||
Extend `MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void Tick_LinearAscendGap1DyPlus1_CompletesWithoutReplan()
|
||||
{
|
||||
World world = LinearParkourScenarioBuilder.Build(gap: 1, deltaY: 1, out Location start, out Location end);
|
||||
var result = BuildLinearPathResult(start, end, MoveType.Parkour);
|
||||
var manager = new PathSegmentManager(debugLog: _ => { }, infoLog: _ => { });
|
||||
var physics = TemplateSimulationRunner.CreateGroundedPhysics(start, yaw: 270f);
|
||||
var input = new MovementInput();
|
||||
|
||||
manager.StartNavigation(new GoalBlock((int)Math.Floor(end.X), (int)end.Y, (int)Math.Floor(end.Z)), result);
|
||||
RunManager(manager, physics, input, world, maxTicks: 420);
|
||||
|
||||
Assert.False(manager.IsNavigating);
|
||||
Assert.Equal(0, manager.ReplanCount);
|
||||
Assert.True(Math.Abs(physics.Position.X - end.X) < 1.0);
|
||||
}
|
||||
```
|
||||
|
||||
Mirror the same structure for:
|
||||
- `gap: 2, deltaY: -2`
|
||||
- `gap: 3, deltaY: -1`
|
||||
- `gap: 4, deltaY: 0`
|
||||
|
||||
- [ ] **Step 3: Run those tests to verify they fail**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~PathSegmentManagerTests|FullyQualifiedName~LivePathingRegressionTests|FullyQualifiedName~SprintJumpTemplateScenarioTests" -v minimal
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
FAIL with at least the four new linear regressions
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add lower-level template regressions for overshoot / fall**
|
||||
|
||||
Extend `MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs` with:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void SprintJumpTemplate_LinearFlatGap4_FinalStop_StopsInsideTargetBlock() { ... }
|
||||
|
||||
[Fact]
|
||||
public void SprintJumpTemplate_LinearDescendGap3DyMinus1_FinalStop_DoesNotOvershoot() { ... }
|
||||
|
||||
[Fact]
|
||||
public void SprintJumpTemplate_LinearAscendGap1DyPlus1_FinalStop_DoesNotFallAfterLanding() { ... }
|
||||
```
|
||||
|
||||
These tests should assert both:
|
||||
|
||||
```csharp
|
||||
Assert.Equal(TemplateState.Complete, state);
|
||||
Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End));
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit the failing tests only after they are green later**
|
||||
|
||||
No commit here yet. Keep them staged with the implementation in Task 4.
|
||||
|
||||
## Task 4: Fix Parkour Execution For The Four Exposed Linear Failures
|
||||
|
||||
**Files:**
|
||||
- Modify: `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs`
|
||||
- Modify: `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs`
|
||||
- Modify: `MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs`
|
||||
- Modify: `MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs`
|
||||
|
||||
- [ ] **Step 1: Fix long-jump overshoot before touching completion gates**
|
||||
|
||||
In `MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs`, prefer early release for true final stops when the predicted holding path overshoots the landing block:
|
||||
|
||||
```csharp
|
||||
if (_segment.ExitTransition == PathTransitionType.FinalStop && !physics.OnGround)
|
||||
{
|
||||
Location? landingIfHolding = PredictLandingPosition(physics, world, holdForward: true, holdSprint: true);
|
||||
Location? landingIfReleased = PredictLandingPosition(physics, world, holdForward: false, holdSprint: false);
|
||||
|
||||
if (landingIfHolding is not null
|
||||
&& landingIfReleased is not null
|
||||
&& !TemplateFootingHelper.IsFootprintInsideTargetBlock(landingIfHolding.Value, ExpectedEnd)
|
||||
&& TemplateFootingHelper.IsFootprintInsideTargetBlock(landingIfReleased.Value, ExpectedEnd))
|
||||
{
|
||||
input.Forward = false;
|
||||
input.Sprint = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tighten final-stop completion so “complete” cannot happen short of the block**
|
||||
|
||||
In `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs`:
|
||||
|
||||
```csharp
|
||||
if (segment.ExitTransition == PathTransitionType.FinalStop
|
||||
&& physics.OnGround
|
||||
&& TemplateFootingHelper.IsFootprintInsideTargetBlock(pos, segment.End)
|
||||
&& !TemplateFootingHelper.WillLeaveTargetBlockNextTick(pos, physics, segment.End))
|
||||
{
|
||||
return TemplateHelper.GetHorizontalSpeed(physics) <= 0.05;
|
||||
}
|
||||
```
|
||||
|
||||
Do not allow `FinalStop` completion through looser `IsNear(...)` checks.
|
||||
|
||||
- [ ] **Step 3: Give landing-recovery / prepare-jump transitions a stricter settle plane**
|
||||
|
||||
In `MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs`, keep `PrepareJump` from completing while the player is still materially before the end plane:
|
||||
|
||||
```csharp
|
||||
if (segment.MoveType == MoveType.Parkour
|
||||
&& segment.ExitTransition == PathTransitionType.PrepareJump)
|
||||
{
|
||||
return physics.OnGround
|
||||
&& TemplateHelper.RemainingDistanceAlongSegment(pos, segment) <= 0.20
|
||||
&& exitSpeed >= segment.ExitHints.MinExitSpeed;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Re-run the focused C# regressions until green**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
dotnet test MinecraftClient.Tests/MinecraftClient.Tests.csproj --filter "FullyQualifiedName~SprintJumpTemplateScenarioTests|FullyQualifiedName~PathSegmentManagerTests|FullyQualifiedName~LivePathingRegressionTests" -v minimal
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
PASS for the four new linear regressions and no collateral failures in the touched suites
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add MinecraftClient/Pathing/Execution/Templates/SprintJumpTemplate.cs \
|
||||
MinecraftClient/Pathing/Execution/Templates/GroundedSegmentController.cs \
|
||||
MinecraftClient/Pathing/Execution/TransitionBrakingPlanner.cs \
|
||||
MinecraftClient/Pathing/Execution/Templates/TemplateHelper.cs \
|
||||
MinecraftClient.Tests/Pathing/Execution/Scenarios/LinearParkourScenarioBuilder.cs \
|
||||
MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs \
|
||||
MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs \
|
||||
MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs
|
||||
git commit -m "pathing: stabilize linear parkour landings"
|
||||
```
|
||||
|
||||
## Task 5: Prove The Full Parallel Linear Matrix On 1.21.11
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/test-parkour.py` only if the previous tasks revealed missing diagnostics
|
||||
- Verify: `/tmp/main-linear-after-update-parallel-fixed.jsonl` replacement run
|
||||
|
||||
- [ ] **Step 1: Run the strict parallel linear sweep**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
export MCC_SERVERS=/home/ryan/Minecraft/Minecraft-Console-Client-milutinke/MinecraftOfficial/downloads
|
||||
python3 tools/test-parkour.py --filter linear --parallel 6 --results /tmp/linear-parallel-zero-replan-final.jsonl
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
25/25 matched expectations
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Machine-check the JSONL**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
rows = [json.loads(line) for line in Path('/tmp/linear-parallel-zero-replan-final.jsonl').read_text().splitlines() if line.strip()]
|
||||
print('rows', len(rows))
|
||||
print('mismatches', sum(not r['matched'] for r in rows))
|
||||
print('pass_replan_nonzero', sum(r['outcome'] == 'pass' and r.get('replan_count', 0) != 0 for r in rows))
|
||||
print('pass_turn_nonzero', sum(r['outcome'] == 'pass' and r.get('turn_stall_count', 0) != 0 for r in rows))
|
||||
PY
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
rows 25
|
||||
mismatches 0
|
||||
pass_replan_nonzero 0
|
||||
pass_turn_nonzero 0
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Save the observed failing logs if anything remains**
|
||||
|
||||
If the run is not green, copy the live log roots before retrying:
|
||||
|
||||
```bash
|
||||
cp /tmp/main-linear-after-update-parallel-fixed.jsonl /tmp/linear-parallel-investigation-last.jsonl
|
||||
cp /tmp/mcc-debug/parkour-*/mcc-debug.log /tmp/ 2>/dev/null || true
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit the final harness/result adjustments**
|
||||
|
||||
```bash
|
||||
git add tools/test-parkour.py tools/tests/test_test_parkour_metrics.py
|
||||
git commit -m "test-harness: verify linear zero-replan parallel sweep"
|
||||
```
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage:
|
||||
- Parallel run on `1.21.11`: covered in Task 5.
|
||||
- Collect test results first: covered in Current Facts and Task 5.
|
||||
- Complete all theory-allowed `linear` passes: covered in Tasks 3-5.
|
||||
- Zero replan detection: covered in Task 2 and Task 5 JSONL validation.
|
||||
- Zero turn-in-place detection: covered in Task 2 via turn-stall sampling and Task 5 JSONL validation.
|
||||
- Autonomous TDD flow: every production change task begins with failing tests.
|
||||
- Placeholder scan:
|
||||
- No `TODO`/`TBD`.
|
||||
- Every code-change task names exact files and commands.
|
||||
- Type consistency:
|
||||
- `LiveMetrics`, `NavigationSample`, `detect_turn_stall`, and `classify_outcome` are named consistently across harness tasks.
|
||||
- `LinearParkourScenarioBuilder.Build(...)` is reused consistently across C# regression tasks.
|
||||
|
|
@ -136,7 +136,7 @@
|
|||
"capability_metric": "gap_blocks",
|
||||
"min_mm": 1,
|
||||
"max_mm": 12,
|
||||
"max_reach": 3,
|
||||
"max_reach": 2,
|
||||
"delta_y": 1.0,
|
||||
"ceiling_height": null,
|
||||
"wall_offset": null,
|
||||
|
|
@ -188,7 +188,7 @@
|
|||
"capability_metric": "gap_blocks",
|
||||
"min_mm": 1,
|
||||
"max_mm": 12,
|
||||
"max_reach": 5,
|
||||
"max_reach": 4,
|
||||
"delta_y": -2.0,
|
||||
"ceiling_height": null,
|
||||
"wall_offset": null,
|
||||
|
|
@ -214,7 +214,7 @@
|
|||
"capability_metric": "gap_blocks",
|
||||
"min_mm": 2,
|
||||
"max_mm": 12,
|
||||
"max_reach": 5,
|
||||
"max_reach": 4,
|
||||
"delta_y": -1.0,
|
||||
"ceiling_height": null,
|
||||
"wall_offset": null,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue