From b0fab26c66fec86dca205ec38963d44add4d8051 Mon Sep 17 00:00:00 2001 From: BruceChen Date: Mon, 13 Apr 2026 15:36:09 +0000 Subject: [PATCH] test: add pathing transition regression coverage --- .../Contracts/PathingContractStore.cs | 71 ++++++ .../Contracts/PathingPlannerContract.cs | 14 + .../Contracts/PathingTimingBudget.cs | 11 + .../Pathing/Execution/FlatWorldTestBuilder.cs | 9 + .../Execution/FlatWorldTestBuilderTests.cs | 17 ++ .../GroundedTemplateConvergenceTests.cs | 128 ++++++++++ .../Execution/LivePathingRegressionTests.cs | 35 ++- .../Execution/PathExecutorCompletionTests.cs | 91 ++++++- .../Execution/PathPlanningContractTests.cs | 29 +++ .../Execution/PathSegmentManagerTests.cs | 240 ++++++++++++++++++ .../Execution/PathTransitionHintsTests.cs | 93 +++++++ .../SprintJumpTemplateScenarioTests.cs | 75 +++++- .../Pathing/Execution/TemplateFootingTests.cs | 51 ++++ .../TransitionBrakingPlannerTests.cs | 53 +++- .../TransitionLookaheadEvaluatorTests.cs | 179 +++++++++++++ .../Pathing/pathing-planner-contracts.json | 37 +++ .../Pathing/pathing-timing-budgets.json | 13 + 17 files changed, 1140 insertions(+), 6 deletions(-) create mode 100644 MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/Contracts/PathingPlannerContract.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/Contracts/PathingTimingBudget.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilderTests.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/PathTransitionHintsTests.cs create mode 100644 MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs create mode 100644 MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json create mode 100644 MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json diff --git a/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs new file mode 100644 index 00000000..9e83cab5 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MinecraftClient.Tests.Pathing.Execution.Contracts; + +public sealed class PathingContractStore +{ + private readonly IReadOnlyDictionary planners; + private readonly IReadOnlyDictionary timings; + + private PathingContractStore( + IReadOnlyDictionary planners, + IReadOnlyDictionary timings) + { + this.planners = planners; + this.timings = timings; + } + + public static PathingContractStore LoadFromRepositoryRoot() + { + string rootPath = FindRepositoryRoot(); + string pathingDir = Path.Combine(rootPath, "MinecraftClient.Tests", "TestData", "Pathing"); + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + options.Converters.Add(new JsonStringEnumConverter()); + + string plannerJson = File.ReadAllText(Path.Combine(pathingDir, "pathing-planner-contracts.json")); + string timingJson = File.ReadAllText(Path.Combine(pathingDir, "pathing-timing-budgets.json")); + + Dictionary plannerContracts = JsonSerializer.Deserialize>(plannerJson, options) + ?? throw new InvalidOperationException("Failed to deserialize planner contracts."); + Dictionary timingBudgets = JsonSerializer.Deserialize>(timingJson, options) + ?? throw new InvalidOperationException("Failed to deserialize timing budgets."); + + return new PathingContractStore(plannerContracts, timingBudgets); + } + + public PathingPlannerContract GetPlanner(string id) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + return planners.TryGetValue(id, out PathingPlannerContract? contract) + ? contract + : throw new KeyNotFoundException($"Planner contract '{id}' was not found."); + } + + public PathingTimingBudget GetTiming(string id) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + return timings.TryGetValue(id, out PathingTimingBudget? budget) + ? budget + : throw new KeyNotFoundException($"Timing budget '{id}' was not found."); + } + + private static string FindRepositoryRoot() + { + DirectoryInfo? current = new(AppContext.BaseDirectory); + + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "MinecraftClient.sln"))) + return current.FullName; + + current = current.Parent; + } + + throw new DirectoryNotFoundException("Unable to locate repository root from current test execution directory."); + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingPlannerContract.cs b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingPlannerContract.cs new file mode 100644 index 00000000..c6770813 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingPlannerContract.cs @@ -0,0 +1,14 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Tests.Pathing.Execution.Contracts; + +public readonly record struct PathingBlock(int X, int Y, int Z); + +public sealed record PathingPlannerSegmentContract( + MoveType Move, + PathingBlock From, + PathingBlock To); + +public sealed record PathingPlannerContract( + PathStatus ExpectedStatus, + PathingPlannerSegmentContract[] Segments); diff --git a/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingTimingBudget.cs b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingTimingBudget.cs new file mode 100644 index 00000000..68c1273b --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingTimingBudget.cs @@ -0,0 +1,11 @@ +using MinecraftClient.Pathing.Core; + +namespace MinecraftClient.Tests.Pathing.Execution.Contracts; + +public sealed record PathingSegmentTimingBudget( + MoveType Move, + int BudgetMs); + +public sealed record PathingTimingBudget( + int TotalBudgetMs, + PathingSegmentTimingBudget[] Segments); diff --git a/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs b/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs index 70b14a63..41a2cf8e 100644 --- a/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs +++ b/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilder.cs @@ -76,6 +76,7 @@ internal static class FlatWorldTestBuilder public static void SetMaterial(World world, int x, int y, int z, Material material) { + EnsureChunkColumn(world, x, z); world.SetBlock(new Location(x, y, z), new Block(ResolveMaterialId(material))); } @@ -98,6 +99,14 @@ internal static class FlatWorldTestBuilder } } + private static void EnsureChunkColumn(World world, int x, int z) + { + int chunkX = (int)Math.Floor(x / 16.0); + int chunkZ = (int)Math.Floor(z / 16.0); + if (world[chunkX, chunkZ] is null) + world[chunkX, chunkZ] = new ChunkColumn(24) { FullyLoaded = true }; + } + private static ushort ResolveMaterialId(Material material) { lock (InitLock) diff --git a/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilderTests.cs b/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilderTests.cs new file mode 100644 index 00000000..bb854916 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/FlatWorldTestBuilderTests.cs @@ -0,0 +1,17 @@ +using MinecraftClient.Mapping; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class FlatWorldTestBuilderTests +{ + [Fact] + public void SetSolid_CreatesMissingChunkColumns() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 118, max: 126); + + FlatWorldTestBuilder.SetSolid(world, 123, 79, 110); + + Assert.Equal(Material.Stone, world.GetBlock(new Location(123, 79, 110)).Type); + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs b/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs index 24f9cade..88f64b96 100644 --- a/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/GroundedTemplateConvergenceTests.cs @@ -1,3 +1,4 @@ +using System; using MinecraftClient.Mapping; using MinecraftClient.Pathing.Core; using MinecraftClient.Pathing.Execution; @@ -30,6 +31,61 @@ public sealed class GroundedTemplateConvergenceTests Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); } + [Fact] + public void WalkTemplate_FinalStop_Completes_WhenCenterStopsInsideTargetBlockNearEdge() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var segment = new PathSegment + { + Start = new Location(2.5, 80, 0.5), + End = new Location(3.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new WalkTemplate(segment, null); + var physics = new PlayerPhysics + { + Position = new Vec3d(3.2897, 80.0, 0.5), + DeltaMovement = Vec3d.Zero, + OnGround = true, + MovementSpeed = 0.1f, + Yaw = 270f + }; + + var input = new MovementInput(); + TemplateState state = template.Tick(new Location(physics.Position.X, physics.Position.Y, physics.Position.Z), physics, input, world); + + Assert.Equal(TemplateState.Complete, state); + } + + [Fact] + public void WalkTemplate_FinalStop_Completes_FromLiveNearGoalState_WithoutFailure() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 95, max: 115); + var segment = new PathSegment + { + Start = new Location(102.5, 80, 100.5), + End = new Location(103.5, 80, 100.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop + }; + + var template = new WalkTemplate(segment, null); + var physics = new PlayerPhysics + { + Position = new Vec3d(103.36, 80.0, 100.50), + DeltaMovement = new Vec3d(0.0346, 0.0, 0.0), + OnGround = true, + MovementSpeed = 0.1f, + Yaw = 270f + }; + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 40, out _); + + Assert.Equal(TemplateState.Complete, state); + } + [Fact] public void WalkTemplate_PrepareJump_CompletesWithoutSettlingOnRunUpBlock() { @@ -59,6 +115,42 @@ public sealed class GroundedTemplateConvergenceTests Assert.True(physics.DeltaMovement.X > 0.02); } + [Fact] + public void WalkTemplate_TurnIntoParkour_CompletesOnlyWhenTurnEntryIsSlowAndJumpReady() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 108, max: 128); + + var current = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(121.5, 80, 110.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.Turn, + ExitHints = new PathTransitionHints(0, 1, 0.08, 0.16, false, true, true, true, 12) + }; + var next = new PathSegment + { + Start = new Location(121.5, 80, 110.5), + End = new Location(121.5, 80, 111.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.PrepareJump, + ExitHints = new PathTransitionHints(0, 1, 0.12, double.PositiveInfinity, false, true, true, false, 10), + PreserveSprint = true + }; + + var template = new WalkTemplate(current, next); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(current.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 140, out Location finalPos); + double horizontalSpeed = Math.Sqrt(physics.DeltaMovement.X * physics.DeltaMovement.X + physics.DeltaMovement.Z * physics.DeltaMovement.Z); + + Assert.Equal(TemplateState.Complete, state); + Assert.True( + TemplateFootingHelper.IsCenterInsideSupportStrip(finalPos, current.End, next.End), + $"finalPos={finalPos} vel={physics.DeltaMovement}"); + Assert.InRange(horizontalSpeed, 0.08, 0.20); + } + [Fact] public void DescendTemplate_LandingRecovery_CompletesOnLandingBlock() { @@ -135,4 +227,40 @@ public sealed class GroundedTemplateConvergenceTests Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}\n{string.Join('\n', trace)}"); Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); } + + [Fact] + public void DescendTemplate_AppliesAirBrake_WhenPlannerRequiresBrake() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: -2, max: 4); + FlatWorldTestBuilder.ClearBox(world, -2, 79, -2, 4, 84, 2); + FlatWorldTestBuilder.SetSolid(world, 1, 79, 0); + + var segment = new PathSegment + { + Start = new Location(0.5, 81, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Descend, + ExitTransition = PathTransitionType.LandingRecovery, + ExitHints = new PathTransitionHints(1, 0, 0.0, 0.0, true, true, false, true, 12) + }; + + var template = new DescendTemplate(segment, null); + var physics = new PlayerPhysics + { + Position = new Vec3d(1.38, 80.56, 0.5), + DeltaMovement = new Vec3d(0.42, -0.22, 0.0), + OnGround = false, + MovementSpeed = 0.1f, + Yaw = 270f + }; + + Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z); + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(segment, null, pos, physics, world); + var input = new MovementInput(); + template.Tick(pos, physics, input, world); + + Assert.Equal(TransitionBrakingDecision.Brake, decision); + Assert.True(input.Back, $"decision={decision} input(F={input.Forward},B={input.Back},S={input.Sprint})"); + Assert.False(input.Forward, $"decision={decision} input(F={input.Forward},B={input.Back},S={input.Sprint})"); + } } diff --git a/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs b/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs index 610d8e2d..1385b245 100644 --- a/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/LivePathingRegressionTests.cs @@ -1,13 +1,40 @@ +using System; +using System.Threading; using MinecraftClient.Mapping; using MinecraftClient.Pathing.Core; using MinecraftClient.Pathing.Execution; using MinecraftClient.Pathing.Execution.Templates; +using MinecraftClient.Pathing.Goals; using Xunit; namespace MinecraftClient.Tests.Pathing.Execution; public sealed class LivePathingRegressionTests { + [Fact] + public void AStar_ThreeByOneRejectionLayout_WithInvalidGoalBlock_RejectsBeforeExecution() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 135, max: 148); + FlatWorldTestBuilder.ClearBox(world, 140, 80, 135, 148, 85, 140); + // Match the live harness: the raised block is reachable, but the requested goal block is not standable. + FlatWorldTestBuilder.SetSolid(world, 143, 80, 138); + + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var finder = new AStarPathFinder(); + + PathResult result = finder.Calculate( + ctx, + startX: 141, + startY: 80, + startZ: 138, + new GoalBlock(144, 81, 138), + CancellationToken.None, + timeoutMs: 2000); + + Assert.Equal(PathStatus.Failed, result.Status); + Assert.Empty(PathSegmentBuilder.FromPath(result.Path)); + } + [Fact] public void SprintJumpTemplate_LandingRecoveryIntoTurn_CompletesInsideLandingBlock() { @@ -24,14 +51,16 @@ public sealed class LivePathingRegressionTests Start = new Location(120.5, 80, 110.5), End = new Location(122.5, 80, 110.5), MoveType = MoveType.Parkour, - ExitTransition = PathTransitionType.LandingRecovery + ExitTransition = PathTransitionType.LandingRecovery, + ExitHints = new PathTransitionHints(0, 1, 0.0, 0.035, true, true, false, true, 12) }; var next = new PathSegment { Start = new Location(122.5, 80, 110.5), End = new Location(122.5, 80, 111.5), MoveType = MoveType.Traverse, - ExitTransition = PathTransitionType.FinalStop + ExitTransition = PathTransitionType.FinalStop, + ExitHints = new PathTransitionHints(0, 1, 0.0, 0.03, true, true, false, false, 12) }; var template = new SprintJumpTemplate(segment, next); @@ -41,5 +70,7 @@ public sealed class LivePathingRegressionTests Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}"); Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End), $"finalPos={finalPos} vel={physics.DeltaMovement}"); + double horizontalSpeed = Math.Sqrt(physics.DeltaMovement.X * physics.DeltaMovement.X + physics.DeltaMovement.Z * physics.DeltaMovement.Z); + Assert.InRange(horizontalSpeed, 0.0, 0.04); } } diff --git a/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs index 488e6882..0b6fd10b 100644 --- a/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/PathExecutorCompletionTests.cs @@ -27,7 +27,13 @@ public sealed class PathExecutorCompletionTests Pitch = 0f, OnGround = true }; - var input = new MovementInput(); + var input = new MovementInput + { + Forward = true, + Sprint = true, + Jump = true, + Back = true + }; var pos = new Location(1.48, 80, 0.5); World world = FlatWorldTestBuilder.CreateStoneFloor(); @@ -39,4 +45,87 @@ public sealed class PathExecutorCompletionTests Assert.False(input.Jump); Assert.False(input.Back); } + + [Fact] + public void Tick_CompletesStraightThreeSegmentFlatPath() + { + List segments = PathSegmentBuilder.FromPath(BuildNodes( + (100, 80, 100, MoveType.Traverse), + (101, 80, 100, MoveType.Traverse), + (102, 80, 100, MoveType.Traverse), + (103, 80, 100, MoveType.Traverse))); + + var executor = new PathExecutor(segments); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segments[0].Start, yaw: 270f); + var input = new MovementInput(); + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 95, max: 115); + + PathExecutorState state = PathExecutorState.InProgress; + for (int tick = 0; tick < 260; 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); + } + + Assert.Equal(PathExecutorState.Complete, state); + } + + [Fact] + public void Tick_ShortAcceptedPath_FromLiveSegmentZeroDriftState_CompletesWithoutFailure() + { + List segments = PathSegmentBuilder.FromPath(BuildNodes( + (100, 80, 100, MoveType.Traverse), + (101, 80, 100, MoveType.Traverse), + (102, 80, 100, MoveType.Traverse), + (103, 80, 100, MoveType.Traverse))); + + var debugLogs = new List(); + var executor = new PathExecutor(segments, debugLogs.Add); + var physics = new PlayerPhysics + { + Position = new Vec3d(101.56, 80.00, 100.74), + DeltaMovement = Vec3d.Zero, + OnGround = true, + MovementSpeed = 0.1f, + Yaw = 270f, + Pitch = 0f + }; + var input = new MovementInput(); + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 95, max: 115); + + PathExecutorState state = PathExecutorState.InProgress; + for (int tick = 0; tick < 220; 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); + } + + Assert.True(state == PathExecutorState.Complete, $"state={state}\n{string.Join('\n', debugLogs)}"); + } + + private static List BuildNodes(params (int x, int y, int z, MoveType moveUsed)[] raw) + { + var result = new List(raw.Length); + for (int i = 0; i < raw.Length; i++) + { + var node = new PathNode(raw[i].x, raw[i].y, raw[i].z); + if (i > 0) + node.MoveUsed = raw[i].moveUsed; + result.Add(node); + } + + return result; + } } diff --git a/MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs new file mode 100644 index 00000000..97e5bcf3 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs @@ -0,0 +1,29 @@ +using MinecraftClient.Pathing.Core; +using MinecraftClient.Tests.Pathing.Execution.Contracts; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class PathPlanningContractTests +{ + [Fact] + public void Get_ManagerAcceptedAscendChain_LoadsExactPlannerContract() + { + var store = PathingContractStore.LoadFromRepositoryRoot(); + + PathingPlannerContract contract = store.GetPlanner("manager-accepted-ascend-chain"); + + Assert.Equal(PathStatus.Success, contract.ExpectedStatus); + Assert.Equal(6, contract.Segments.Length); + + PathingPlannerSegmentContract firstSegment = contract.Segments[0]; + Assert.Equal(MoveType.Diagonal, firstSegment.Move); + Assert.Equal(new PathingBlock(171, 80, 160), firstSegment.From); + Assert.Equal(new PathingBlock(172, 80, 161), firstSegment.To); + + PathingPlannerSegmentContract lastSegment = contract.Segments[5]; + Assert.Equal(MoveType.Ascend, lastSegment.Move); + Assert.Equal(new PathingBlock(176, 82, 162), lastSegment.From); + Assert.Equal(new PathingBlock(177, 83, 162), lastSegment.To); + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs new file mode 100644 index 00000000..9669618c --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/PathSegmentManagerTests.cs @@ -0,0 +1,240 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Pathing.Goals; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class PathSegmentManagerTests +{ + [Fact] + public void Tick_AcceptedAscendChain_FromSterileStart_CompletesWithoutReplan() + { + var debugLogs = new List(); + var infoLogs = new List(); + var manager = new PathSegmentManager(debugLog: debugLogs.Add, infoLog: infoLogs.Add); + var goal = new GoalBlock(177, 83, 162); + var path = BuildNodes( + (171, 80, 160, MoveType.Traverse), + (172, 80, 161, MoveType.Diagonal), + (173, 80, 162, MoveType.Diagonal), + (174, 80, 162, MoveType.Traverse), + (175, 81, 162, MoveType.Ascend), + (176, 82, 162, MoveType.Ascend), + (177, 83, 162, MoveType.Ascend)); + var result = new PathResult(PathStatus.Success, path, nodesExplored: 7, elapsedMs: 1); + + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 158, max: 180); + FlatWorldTestBuilder.ClearBox(world, 170, 80, 160, 178, 85, 168); + FlatWorldTestBuilder.SetSolid(world, 175, 80, 162); + FlatWorldTestBuilder.SetSolid(world, 176, 81, 162); + FlatWorldTestBuilder.SetSolid(world, 177, 82, 162); + + var physics = TemplateSimulationRunner.CreateGroundedPhysics(new Location(171.5, 80, 160.5), yaw: 315f); + var input = new MovementInput(); + var recentTrace = new Queue(); + + manager.StartNavigation(goal, result); + + for (int tick = 0; tick < 420 && 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} onGround={physics.OnGround} yaw={physics.Yaw:F1} input(F={input.Forward},B={input.Back},J={input.Jump},S={input.Sprint})"); + if (recentTrace.Count > 40) + recentTrace.Dequeue(); + if (!manager.IsNavigating) + break; + + physics.ApplyInput(input); + physics.Tick(world); + } + + Assert.True(!manager.IsNavigating && manager.ReplanCount == 0, + $"replanCount={manager.ReplanCount}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}\ntrace={string.Join('\n', recentTrace)}"); + } + + [Fact] + public void Tick_AcceptedDescendStaircase_FromSterileStart_CompletesWithoutReplan() + { + var debugLogs = new List(); + var infoLogs = new List(); + var manager = new PathSegmentManager(debugLog: debugLogs.Add, infoLog: infoLogs.Add); + var goal = new GoalBlock(367, 80, 360); + var path = BuildNodes( + (362, 85, 360, MoveType.Traverse), + (364, 83, 360, MoveType.Descend), + (366, 81, 360, MoveType.Descend), + (367, 80, 360, MoveType.Descend)); + var result = new PathResult(PathStatus.Success, path, nodesExplored: 4, elapsedMs: 1); + + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 358, max: 369); + FlatWorldTestBuilder.ClearBox(world, 360, 79, 358, 369, 85, 362); + FlatWorldTestBuilder.FillSolid(world, 362, 84, 359, 362, 84, 361); + FlatWorldTestBuilder.FillSolid(world, 363, 83, 359, 363, 83, 361); + FlatWorldTestBuilder.FillSolid(world, 364, 82, 359, 364, 82, 361); + FlatWorldTestBuilder.FillSolid(world, 365, 81, 359, 365, 81, 361); + FlatWorldTestBuilder.FillSolid(world, 366, 80, 359, 366, 80, 361); + FlatWorldTestBuilder.FillSolid(world, 367, 79, 359, 367, 79, 361); + + var physics = TemplateSimulationRunner.CreateGroundedPhysics(new Location(362.5, 85, 360.5), yaw: 270f); + var input = new MovementInput(); + var recentTrace = new Queue(); + + manager.StartNavigation(goal, result); + + for (int tick = 0; tick < 420 && 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} onGround={physics.OnGround} yaw={physics.Yaw:F1} input(F={input.Forward},B={input.Back},J={input.Jump},S={input.Sprint})"); + if (recentTrace.Count > 40) + recentTrace.Dequeue(); + if (!manager.IsNavigating) + break; + + physics.ApplyInput(input); + physics.Tick(world); + } + + Assert.True(!manager.IsNavigating && manager.ReplanCount == 0, + $"replanCount={manager.ReplanCount}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}\ntrace={string.Join('\n', recentTrace)}"); + } + + [Fact] + public void Tick_CompletesNavigation_WhenReplanStartsInsideGoalBlock() + { + var infoLogs = new List(); + var manager = new PathSegmentManager(infoLog: infoLogs.Add); + var goal = new GoalBlock(1, 80, 0); + var path = new[] + { + new PathNode(0, 80, 0), + new PathNode(1, 80, 0) { MoveUsed = MoveType.Traverse } + }; + var result = new PathResult(PathStatus.Success, path, nodesExplored: 2, elapsedMs: 1); + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 0, max: 4); + var physics = new PlayerPhysics + { + Position = new Vec3d(1.10, 80.0, 0.5), + DeltaMovement = Vec3d.Zero, + OnGround = false, + MovementSpeed = 0.1f, + Yaw = 270f + }; + var input = new MovementInput(); + + manager.StartNavigation(goal, result); + + for (int tick = 0; tick < 60 && manager.IsNavigating; tick++) + { + Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z); + manager.Tick(pos, physics, input, world); + } + + Assert.False(manager.IsNavigating); + Assert.Null(manager.Goal); + Assert.Equal(1, manager.ReplanCount); + } + + [Fact] + public void Tick_ShortAcceptedPath_FromSterileStart_CompletesWithoutIncrementingReplanCount() + { + var infoLogs = new List(); + var manager = new PathSegmentManager(infoLog: infoLogs.Add); + var goal = new GoalBlock(103, 80, 100); + var path = new[] + { + new PathNode(100, 80, 100), + new PathNode(101, 80, 100) { MoveUsed = MoveType.Traverse }, + new PathNode(102, 80, 100) { MoveUsed = MoveType.Traverse }, + new PathNode(103, 80, 100) { MoveUsed = MoveType.Traverse } + }; + var result = new PathResult(PathStatus.Success, path, nodesExplored: 4, elapsedMs: 1); + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 95, max: 115); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(new Location(100.5, 80, 100.5), yaw: 270f); + var input = new MovementInput(); + + manager.StartNavigation(goal, result); + + for (int tick = 0; tick < 260 && manager.IsNavigating; tick++) + { + input.Reset(); + Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z); + manager.Tick(pos, physics, input, world); + if (!manager.IsNavigating) + break; + + physics.ApplyInput(input); + physics.Tick(world); + } + + Assert.False(manager.IsNavigating); + Assert.Equal(0, manager.ReplanCount); + } + + [Fact] + public void Tick_ShortAcceptedPath_FromLiveSegmentZeroDriftState_CompletesWithoutReplan() + { + var debugLogs = new List(); + var infoLogs = new List(); + var manager = new PathSegmentManager(debugLog: debugLogs.Add, infoLog: infoLogs.Add); + var goal = new GoalBlock(103, 80, 100); + var path = new[] + { + new PathNode(100, 80, 100), + new PathNode(101, 80, 100) { MoveUsed = MoveType.Traverse }, + new PathNode(102, 80, 100) { MoveUsed = MoveType.Traverse }, + new PathNode(103, 80, 100) { MoveUsed = MoveType.Traverse } + }; + var result = new PathResult(PathStatus.Success, path, nodesExplored: 4, elapsedMs: 1); + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 95, max: 115); + var physics = new PlayerPhysics + { + Position = new Vec3d(101.56, 80.00, 100.74), + DeltaMovement = Vec3d.Zero, + OnGround = true, + MovementSpeed = 0.1f, + Yaw = 270f, + Pitch = 0f + }; + var input = new MovementInput(); + + manager.StartNavigation(goal, result); + + for (int tick = 0; tick < 260 && manager.IsNavigating; tick++) + { + input.Reset(); + Location pos = new(physics.Position.X, physics.Position.Y, physics.Position.Z); + manager.Tick(pos, physics, input, world); + if (!manager.IsNavigating) + break; + + physics.ApplyInput(input); + physics.Tick(world); + } + + Assert.True(!manager.IsNavigating && manager.ReplanCount == 0, + $"replanCount={manager.ReplanCount}\ninfo={string.Join('\n', infoLogs)}\ndebug={string.Join('\n', debugLogs)}"); + } + + private static List BuildNodes(params (int x, int y, int z, MoveType moveUsed)[] raw) + { + var result = new List(raw.Length); + for (int i = 0; i < raw.Length; i++) + { + var node = new PathNode(raw[i].x, raw[i].y, raw[i].z); + if (i > 0) + node.MoveUsed = raw[i].moveUsed; + result.Add(node); + } + + return result; + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/PathTransitionHintsTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathTransitionHintsTests.cs new file mode 100644 index 00000000..b2e46758 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/PathTransitionHintsTests.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class PathTransitionHintsTests +{ + [Fact] + public void FromPath_AssignsTurnHints_WhenNextSegmentChangesHeading() + { + var nodes = BuildNodes( + (0, 80, 0, MoveType.Traverse), + (1, 80, 0, MoveType.Traverse), + (1, 80, 1, MoveType.Traverse)); + + List segments = PathSegmentBuilder.FromPath(nodes); + PathTransitionHints hints = segments[0].ExitHints; + + Assert.Equal(PathTransitionType.Turn, segments[0].ExitTransition); + Assert.True(hints.RequireStableFooting); + Assert.True(hints.RequireGrounded); + Assert.Equal(0, hints.DesiredHeadingX); + Assert.Equal(1, hints.DesiredHeadingZ); + Assert.InRange(hints.MaxExitSpeed, 0.0, 0.05); + } + + [Fact] + public void FromPath_AssignsJumpReadyHints_WhenNextSegmentIsParkour() + { + var nodes = BuildNodes( + (120, 80, 110, MoveType.Traverse), + (121, 80, 110, MoveType.Traverse), + (123, 80, 110, MoveType.Parkour)); + + List segments = PathSegmentBuilder.FromPath(nodes); + PathTransitionHints hints = segments[0].ExitHints; + + Assert.Equal(PathTransitionType.PrepareJump, segments[0].ExitTransition); + Assert.True(hints.RequireJumpReady); + Assert.False(hints.RequireStableFooting); + Assert.Equal(1, hints.DesiredHeadingX); + Assert.Equal(0, hints.DesiredHeadingZ); + Assert.True(hints.MinExitSpeed >= 0.10, $"MinExitSpeed={hints.MinExitSpeed}"); + } + + [Fact] + public void FromPath_AssignsZeroRunUpSpeedHints_WhenNextSegmentIsAscend() + { + var nodes = BuildNodes( + (174, 80, 162, MoveType.Traverse), + (175, 80, 162, MoveType.Traverse), + (176, 81, 162, MoveType.Ascend)); + + List segments = PathSegmentBuilder.FromPath(nodes); + PathTransitionHints hints = segments[0].ExitHints; + + Assert.Equal(PathTransitionType.PrepareJump, segments[0].ExitTransition); + Assert.True(hints.RequireJumpReady); + Assert.Equal(0.0, hints.MinExitSpeed); + } + + [Fact] + public void FromPath_AssignsPreciseStopHints_WhenSegmentIsFinalStop() + { + var nodes = BuildNodes( + (10, 80, 10, MoveType.Traverse), + (11, 80, 10, MoveType.Traverse)); + + List segments = PathSegmentBuilder.FromPath(nodes); + PathTransitionHints hints = segments[0].ExitHints; + + Assert.Equal(PathTransitionType.FinalStop, segments[0].ExitTransition); + Assert.True(hints.RequireStableFooting); + Assert.True(hints.RequireGrounded); + Assert.InRange(hints.MaxExitSpeed, 0.0, 0.02); + } + + private static List BuildNodes(params (int x, int y, int z, MoveType moveUsed)[] raw) + { + var result = new List(raw.Length); + for (int i = 0; i < raw.Length; i++) + { + var node = new PathNode(raw[i].x, raw[i].y, raw[i].z); + if (i > 0) + node.MoveUsed = raw[i].moveUsed; + result.Add(node); + } + + return result; + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs b/MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs index 6958f6f6..ad055b7b 100644 --- a/MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/SprintJumpTemplateScenarioTests.cs @@ -1,7 +1,9 @@ +using System; using MinecraftClient.Mapping; using MinecraftClient.Pathing.Core; using MinecraftClient.Pathing.Execution; using MinecraftClient.Pathing.Execution.Templates; +using MinecraftClient.Physics; using Xunit; namespace MinecraftClient.Tests.Pathing.Execution; @@ -59,7 +61,7 @@ public sealed class SprintJumpTemplateScenarioTests } [Fact] - public void SprintJumpTemplate_TwoBlockGap_LandingRecovery_CompletesInsideLandingBlock() + public void SprintJumpTemplate_TwoBlockGap_LandingRecovery_CompletesOnTurnEntrySupportStrip() { World world = FlatWorldTestBuilder.CreateStoneFloor(min: 0, max: 16); FlatWorldTestBuilder.ClearBox(world, 0, 79, 0, 4, 82, 2); @@ -90,6 +92,75 @@ public sealed class SprintJumpTemplateScenarioTests TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 140, out Location finalPos); Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}"); - Assert.True(TemplateFootingHelper.IsFootprintInsideTargetBlock(finalPos, segment.End)); + Assert.True( + TemplateFootingHelper.IsCenterInsideSupportStrip(finalPos, segment.End, next.End), + $"finalPos={finalPos} vel={physics.DeltaMovement}"); + } + + [Fact] + public void SprintJumpTemplate_LandingRecoveryIntoTurn_CompletesWithLowResidualSpeed() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 108, max: 126); + FlatWorldTestBuilder.ClearBox(world, 118, 79, 108, 126, 90, 112); + FlatWorldTestBuilder.SetSolid(world, 120, 79, 110); + FlatWorldTestBuilder.SetSolid(world, 123, 79, 110); + FlatWorldTestBuilder.SetSolid(world, 123, 79, 111); + + var segment = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(123.5, 80, 110.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.LandingRecovery, + ExitHints = new PathTransitionHints(0, 1, 0.0, 0.035, true, true, false, true, 12) + }; + var next = new PathSegment + { + Start = new Location(123.5, 80, 110.5), + End = new Location(123.5, 80, 111.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.FinalStop, + ExitHints = new PathTransitionHints(0, 1, 0.0, 0.03, true, true, false, false, 12) + }; + + var template = new SprintJumpTemplate(segment, next); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f); + + TemplateState state = TemplateSimulationRunner.Run(template, physics, world, maxTicks: 160, out Location finalPos); + double horizontalSpeed = Math.Sqrt(physics.DeltaMovement.X * physics.DeltaMovement.X + physics.DeltaMovement.Z * physics.DeltaMovement.Z); + + Assert.True(state == TemplateState.Complete, $"state={state} finalPos={finalPos} vel={physics.DeltaMovement}"); + Assert.True( + TemplateFootingHelper.IsCenterInsideSupportStrip(finalPos, segment.End, next.End), + $"finalPos={finalPos} vel={physics.DeltaMovement}"); + Assert.InRange(horizontalSpeed, 0.0, 0.04); + } + + [Fact] + public void SprintJumpTemplate_ThreeBlockGap_WithIsolatedTakeoffBlock_JumpsImmediately() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 108, max: 126); + FlatWorldTestBuilder.ClearBox(world, 118, 79, 108, 126, 90, 112); + FlatWorldTestBuilder.SetSolid(world, 120, 79, 110); + FlatWorldTestBuilder.SetSolid(world, 123, 79, 110); + + var segment = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(123.5, 80, 110.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.FinalStop, + }; + + var template = new SprintJumpTemplate(segment, null); + var physics = TemplateSimulationRunner.CreateGroundedPhysics(segment.Start, yaw: 270f); + var input = new MovementInput(); + + TemplateState state = template.Tick(segment.Start, physics, input, world); + + Assert.Equal(TemplateState.InProgress, state); + Assert.True(input.Forward); + Assert.True(input.Sprint); + Assert.True(input.Jump); } } diff --git a/MinecraftClient.Tests/Pathing/Execution/TemplateFootingTests.cs b/MinecraftClient.Tests/Pathing/Execution/TemplateFootingTests.cs index 15479a12..1502350c 100644 --- a/MinecraftClient.Tests/Pathing/Execution/TemplateFootingTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/TemplateFootingTests.cs @@ -44,4 +44,55 @@ public sealed class TemplateFootingTests Assert.True(exitsNextTick); } + + [Fact] + public void IsCenterInsideTargetBlock_ReturnsTrue_WhenPlayerStopsNearEdge() + { + bool inside = TemplateFootingHelper.IsCenterInsideTargetBlock( + new Location(10.29, 80.0, 4.50), + new Location(10.50, 80.0, 4.50)); + + Assert.True(inside); + } + + [Fact] + public void IsFootprintInsideSupportStrip_ReturnsTrue_WhenPlayerStraddlesTurnEntryBlocks() + { + bool inside = TemplateFootingHelper.IsFootprintInsideSupportStrip( + new Location(123.46, 80.0, 110.77), + new Location(123.50, 80.0, 110.50), + new Location(123.50, 80.0, 111.50)); + + Assert.True(inside); + } + + [Fact] + public void WillLeaveSupportStripNextTick_ReturnsFalse_WhenLowSpeedStaysOnTurnEntryBlocks() + { + var physics = new PlayerPhysics + { + Position = new Vec3d(123.46, 80.0, 110.77), + DeltaMovement = new Vec3d(-0.0199, 0.0, 0.0040), + OnGround = true + }; + + bool exitsNextTick = TemplateFootingHelper.WillLeaveSupportStripNextTick( + new Location(123.46, 80.0, 110.77), + physics, + new Location(123.50, 80.0, 110.50), + new Location(123.50, 80.0, 111.50)); + + Assert.False(exitsNextTick); + } + + [Fact] + public void IsCenterInsideSupportStrip_ReturnsTrue_WhenLowSpeedTurnEntryStopsOnSeam() + { + bool inside = TemplateFootingHelper.IsCenterInsideSupportStrip( + new Location(123.46, 80.0, 110.77), + new Location(123.50, 80.0, 110.50), + new Location(123.50, 80.0, 111.50)); + + Assert.True(inside); + } } diff --git a/MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs b/MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs index 6d22b78e..0a7b7515 100644 --- a/MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/TransitionBrakingPlannerTests.cs @@ -19,6 +19,7 @@ public sealed class TransitionBrakingPlannerTests End = new Location(1.5, 80, 0.5), MoveType = MoveType.Traverse, ExitTransition = PathTransitionType.ContinueStraight, + ExitHints = new PathTransitionHints(1, 0, 0.0, double.PositiveInfinity, false, false, false, false, 8), PreserveSprint = true }; @@ -29,6 +30,50 @@ public sealed class TransitionBrakingPlannerTests Assert.False(decision.HoldBack); } + [Fact] + public void Plan_Brakes_ForTurnEntryRequiringSlowSpeed() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var physics = CreatePhysics(0.156, 0.0, onGround: true); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.Turn, + ExitHints = new PathTransitionHints(0, 1, 0.0, 0.035, true, true, false, true, 12), + PreserveSprint = false + }; + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(current, null, new Location(1.38, 80, 0.5), physics, world); + + Assert.False(decision.HoldForward); + Assert.False(decision.HoldSprint); + Assert.True(decision.HoldBack); + } + + [Fact] + public void Plan_Carries_ForPrepareJumpNeedingRunUpSpeed() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var physics = CreatePhysics(0.0, 0.0, onGround: true); + var current = new PathSegment + { + 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.12, double.PositiveInfinity, false, true, true, false, 10), + PreserveSprint = true + }; + + TransitionBrakingDecision decision = TransitionBrakingPlanner.Plan(current, null, new Location(1.02, 80, 0.5), physics, world); + + Assert.True(decision.HoldForward); + Assert.True(decision.HoldSprint); + Assert.False(decision.HoldBack); + } + [Fact] public void Plan_BackBrakes_ForFinalStop_WhenRemainingRunwayIsTooShort() { @@ -40,6 +85,7 @@ public sealed class TransitionBrakingPlannerTests End = new Location(1.5, 80, 0.5), MoveType = MoveType.Traverse, ExitTransition = PathTransitionType.FinalStop, + ExitHints = new PathTransitionHints(1, 0, 0.0, 0.03, true, true, false, false, 12), PreserveSprint = false }; @@ -61,6 +107,7 @@ public sealed class TransitionBrakingPlannerTests End = new Location(1.5, 80, 0.5), MoveType = MoveType.Traverse, ExitTransition = PathTransitionType.FinalStop, + ExitHints = new PathTransitionHints(1, 0, 0.0, 0.03, true, true, false, false, 12), PreserveSprint = false }; @@ -81,6 +128,7 @@ public sealed class TransitionBrakingPlannerTests End = new Location(123.5, 80, 110.5), MoveType = MoveType.Parkour, ExitTransition = PathTransitionType.Turn, + ExitHints = new PathTransitionHints(0, 1, 0.0, 0.035, true, true, false, true, 12), PreserveSprint = false }; var next = new PathSegment @@ -91,7 +139,9 @@ public sealed class TransitionBrakingPlannerTests ExitTransition = PathTransitionType.FinalStop }; - bool release = TransitionBrakingPlanner.ShouldReleaseForwardInAir(current, next, new Location(123.18, 80.92, 110.5), physics); + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 108, max: 126); + + bool release = TransitionBrakingPlanner.ShouldReleaseForwardInAir(current, next, new Location(123.18, 80.92, 110.5), physics, world); Assert.True(release); } @@ -112,6 +162,7 @@ public sealed class TransitionBrakingPlannerTests End = new Location(122.5, 80, 110.5), MoveType = MoveType.Parkour, ExitTransition = PathTransitionType.LandingRecovery, + ExitHints = new PathTransitionHints(0, 1, 0.0, 0.035, true, true, false, true, 12), PreserveSprint = false }; var next = new PathSegment diff --git a/MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs b/MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs new file mode 100644 index 00000000..800327a9 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/TransitionLookaheadEvaluatorTests.cs @@ -0,0 +1,179 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class TransitionLookaheadEvaluatorTests +{ + [Fact] + public void ChooseGroundProfile_PicksBrake_WhenTurnEntryCapsResidualSpeed() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var current = new PathSegment + { + Start = new Location(0.5, 80, 0.5), + End = new Location(1.5, 80, 0.5), + MoveType = MoveType.Traverse, + ExitTransition = PathTransitionType.Turn, + ExitHints = new PathTransitionHints(0, 1, 0.0, 0.035, true, true, false, true, 12) + }; + + var physics = new PlayerPhysics + { + Position = new Vec3d(1.34, 80.0, 0.5), + DeltaMovement = new Vec3d(0.156, 0.0, 0.0), + OnGround = true, + MovementSpeed = 0.1f, + Yaw = 270f + }; + + TransitionInputProfile profile = TransitionLookaheadEvaluator.ChooseGroundProfile( + current, + new Location(1.34, 80.0, 0.5), + physics, + world); + + Assert.Equal(TransitionInputProfile.Brake, profile); + } + + [Fact] + public void ChooseGroundProfile_PicksCarry_WhenPrepareJumpNeedsRunUpSpeed() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(); + var current = new PathSegment + { + 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.12, double.PositiveInfinity, false, true, true, false, 10), + PreserveSprint = true + }; + + var physics = new PlayerPhysics + { + Position = new Vec3d(1.02, 80.0, 0.5), + DeltaMovement = new Vec3d(0.086, 0.0, 0.0), + OnGround = true, + MovementSpeed = 0.1f, + Yaw = 270f + }; + + TransitionInputProfile profile = TransitionLookaheadEvaluator.ChooseGroundProfile( + current, + new Location(1.02, 80.0, 0.5), + physics, + world); + + Assert.Equal(TransitionInputProfile.Carry, profile); + } + + [Fact] + public void ChooseAirProfile_PicksRelease_WhenLandingNeedsSlowStableEntry() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 108, max: 126); + var current = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(123.5, 80, 110.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.LandingRecovery, + ExitHints = new PathTransitionHints(0, 1, 0.0, 0.035, true, true, false, true, 12) + }; + + var physics = new PlayerPhysics + { + Position = new Vec3d(123.06, 80.92, 110.5), + DeltaMovement = new Vec3d(0.31, 0.0, 0.0), + OnGround = false, + MovementSpeed = 0.1f, + Yaw = 270f + }; + + TransitionInputProfile profile = TransitionLookaheadEvaluator.ChooseAirProfile( + current, + new Location(123.06, 80.92, 110.5), + physics, + world); + + Assert.Equal(TransitionInputProfile.AirRelease, profile); + } + + [Fact] + public void ChooseAirProfile_KeepsForward_WhenThreeBlockLandingRecoveryIsStillShort() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 108, max: 126); + FlatWorldTestBuilder.ClearBox(world, 118, 79, 108, 126, 90, 112); + FlatWorldTestBuilder.SetSolid(world, 120, 79, 110); + FlatWorldTestBuilder.SetSolid(world, 123, 79, 110); + FlatWorldTestBuilder.SetSolid(world, 123, 79, 111); + + var current = new PathSegment + { + Start = new Location(120.5, 80, 110.5), + End = new Location(123.5, 80, 110.5), + MoveType = MoveType.Parkour, + ExitTransition = PathTransitionType.LandingRecovery, + ExitHints = new PathTransitionHints(0, 1, 0.0, 0.035, true, true, false, true, 12) + }; + + var physics = new PlayerPhysics + { + Position = new Vec3d(122.13, 81.02, 110.5), + DeltaMovement = new Vec3d(0.1798, -0.2277, 0.0), + OnGround = false, + MovementSpeed = 0.1f, + Yaw = 270f + }; + + TransitionInputProfile profile = TransitionLookaheadEvaluator.ChooseAirProfile( + current, + new Location(122.13, 81.02, 110.5), + physics, + world); + + Assert.Equal(TransitionInputProfile.AirHoldForward, profile); + } + + [Fact] + public void ChooseGroundProfile_PicksCarry_WhenFinalDescendHasNotClearedUpperSupport() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 360, max: 369); + FlatWorldTestBuilder.ClearBox(world, 360, 79, 358, 369, 85, 362); + FlatWorldTestBuilder.FillSolid(world, 362, 84, 359, 362, 84, 361); + FlatWorldTestBuilder.FillSolid(world, 363, 83, 359, 363, 83, 361); + FlatWorldTestBuilder.FillSolid(world, 364, 82, 359, 364, 82, 361); + FlatWorldTestBuilder.FillSolid(world, 365, 81, 359, 365, 81, 361); + FlatWorldTestBuilder.FillSolid(world, 366, 80, 359, 366, 80, 361); + FlatWorldTestBuilder.FillSolid(world, 367, 79, 359, 367, 79, 361); + + var current = new PathSegment + { + Start = new Location(366.5, 81, 360.5), + End = new Location(367.5, 80, 360.5), + MoveType = MoveType.Descend, + ExitTransition = PathTransitionType.FinalStop, + ExitHints = new PathTransitionHints(1, 0, 0.0, 0.02, true, true, false, false, 12) + }; + + var physics = new PlayerPhysics + { + Position = new Vec3d(367.2316, 81.0, 360.4698), + DeltaMovement = Vec3d.Zero, + OnGround = true, + MovementSpeed = 0.1f, + Yaw = 270f + }; + + TransitionInputProfile profile = TransitionLookaheadEvaluator.ChooseGroundProfile( + current, + new Location(367.2316, 81.0, 360.4698), + physics, + world); + + Assert.Equal(TransitionInputProfile.Carry, profile); + } +} diff --git a/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json b/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json new file mode 100644 index 00000000..1bb20015 --- /dev/null +++ b/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json @@ -0,0 +1,37 @@ +{ + "manager-accepted-ascend-chain": { + "expectedStatus": "Success", + "segments": [ + { + "move": "Diagonal", + "from": { "x": 171, "y": 80, "z": 160 }, + "to": { "x": 172, "y": 80, "z": 161 } + }, + { + "move": "Diagonal", + "from": { "x": 172, "y": 80, "z": 161 }, + "to": { "x": 173, "y": 80, "z": 162 } + }, + { + "move": "Traverse", + "from": { "x": 173, "y": 80, "z": 162 }, + "to": { "x": 174, "y": 80, "z": 162 } + }, + { + "move": "Ascend", + "from": { "x": 174, "y": 80, "z": 162 }, + "to": { "x": 175, "y": 81, "z": 162 } + }, + { + "move": "Ascend", + "from": { "x": 175, "y": 81, "z": 162 }, + "to": { "x": 176, "y": 82, "z": 162 } + }, + { + "move": "Ascend", + "from": { "x": 176, "y": 82, "z": 162 }, + "to": { "x": 177, "y": 83, "z": 162 } + } + ] + } +} diff --git a/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json b/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json new file mode 100644 index 00000000..156d40db --- /dev/null +++ b/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json @@ -0,0 +1,13 @@ +{ + "manager-accepted-ascend-chain": { + "totalBudgetMs": 0, + "segments": [ + { "move": "Diagonal", "budgetMs": 0 }, + { "move": "Diagonal", "budgetMs": 0 }, + { "move": "Traverse", "budgetMs": 0 }, + { "move": "Ascend", "budgetMs": 0 }, + { "move": "Ascend", "budgetMs": 0 }, + { "move": "Ascend", "budgetMs": 0 } + ] + } +}