diff --git a/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs index 6a49d2c4..060e1e3f 100644 --- a/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs +++ b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs @@ -116,8 +116,11 @@ public sealed class PathingContractStore if (string.IsNullOrWhiteSpace(contract.ScenarioId)) throw new InvalidDataException("Planner contract has blank scenario id."); - if (contract.Segments is null || contract.Segments.Count == 0) - throw new InvalidDataException($"Planner contract '{contract.ScenarioId}' must contain at least one segment."); + if (contract.Segments is null) + throw new InvalidDataException($"Planner contract '{contract.ScenarioId}' has null segments."); + + if (contract.ExpectedStatus != PathStatus.Failed && contract.Segments.Count == 0) + throw new InvalidDataException($"Planner contract '{contract.ScenarioId}' must contain at least one segment unless the expected status is Failed."); var normalizedSegments = new List(contract.Segments.Count); for (int i = 0; i < contract.Segments.Count; i++) @@ -134,8 +137,8 @@ public sealed class PathingContractStore if (string.IsNullOrWhiteSpace(budget.ScenarioId)) throw new InvalidDataException("Timing budget has blank scenario id."); - if (budget.Segments is null || budget.Segments.Count == 0) - throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' must contain at least one segment."); + if (budget.Segments is null) + throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' has null segments."); if (budget.ExpectedTotalTicks < 0 || budget.MaxTotalTicks < 0) throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' total ticks must be nonnegative."); @@ -143,6 +146,9 @@ public sealed class PathingContractStore if (budget.ExpectedTotalTicks > budget.MaxTotalTicks) throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' has ExpectedTotalTicks greater than MaxTotalTicks."); + if (budget.Segments.Count == 0 && (budget.ExpectedTotalTicks != 0 || budget.MaxTotalTicks != 0)) + throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' must use zero totals when it has no segments."); + var normalizedSegments = new List(budget.Segments.Count); for (int i = 0; i < budget.Segments.Count; i++) { diff --git a/MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs index d8ae6e19..ae59865d 100644 --- a/MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs @@ -1,4 +1,5 @@ using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; using MinecraftClient.Tests.Pathing.Execution.Contracts; using Xunit; @@ -211,4 +212,17 @@ public sealed class PathPlanningContractTests Assert.Contains("sequence-mismatch", error.Message); Assert.Contains("segment 1", error.Message); } + + [Theory] + [InlineData("same-move-ascend-staircase")] + [InlineData("same-move-descend-staircase")] + [InlineData("rejected-3x1-invalid-goal")] + public void Scenario_PlannerMatchesContract(string scenarioId) + { + PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId); + PathResult planResult = PathingScenarioRunner.PlanOnly(scenario); + PathingPlannerContract contract = PathingContractStore.LoadFromRepositoryRoot().GetPlanner(scenarioId); + + PathingContractAssert.PlannerMatches(contract, PathSegmentBuilder.FromPath(planResult.Path), planResult); + } } diff --git a/MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs index c9daf70f..70f0e057 100644 --- a/MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs @@ -1,3 +1,4 @@ +using MinecraftClient.Tests.Pathing.Execution.Contracts; using Xunit; namespace MinecraftClient.Tests.Pathing.Execution; @@ -16,4 +17,16 @@ public sealed class PathTimingContractTests Assert.Equal(6, result.SegmentRuns.Count); Assert.All(result.SegmentRuns, run => Assert.True(run.ElapsedTicks > 0)); } + + [Theory] + [InlineData("same-move-ascend-staircase")] + [InlineData("same-move-descend-staircase")] + public void Scenario_ExecutionStaysWithinTimingBudget(string scenarioId) + { + PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId); + PathingTimingBudget budget = PathingContractStore.LoadFromRepositoryRoot().GetTiming(scenarioId); + PathingScenarioResult result = PathingScenarioRunner.RunAccepted(scenario); + + PathingContractAssert.TimingMatches(budget, result); + } } diff --git a/MinecraftClient.Tests/Pathing/Execution/PathingContractBootstrapTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathingContractBootstrapTests.cs new file mode 100644 index 00000000..8842f84a --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/PathingContractBootstrapTests.cs @@ -0,0 +1,26 @@ +using MinecraftClient.Pathing.Core; +using Xunit; +using Xunit.Abstractions; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class PathingContractBootstrapTests +{ + private readonly ITestOutputHelper _output; + + public PathingContractBootstrapTests(ITestOutputHelper output) => _output = output; + + [Theory] + [InlineData("same-move-ascend-staircase")] + [InlineData("same-move-descend-staircase")] + [InlineData("rejected-3x1-invalid-goal")] + public void PrintShortRouteContractFragments(string scenarioId) + { + PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId); + PathResult planResult = PathingScenarioRunner.PlanOnly(scenario); + + _output.WriteLine(PathingContractBootstrapWriter.WritePlannerFragment(scenarioId, planResult)); + if (planResult.Status == PathStatus.Success) + _output.WriteLine(PathingContractBootstrapWriter.WriteTimingFragment(scenarioId, PathingScenarioRunner.RunAccepted(scenario))); + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs b/MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs index ec3a82cc..b89511dc 100644 --- a/MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs +++ b/MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs @@ -16,6 +16,33 @@ internal static class PathingExecutionScenarioCatalog StartYaw = 315f, MaxExecutionTicks = 420 }, + "same-move-ascend-staircase" => new PathingExecutionScenario + { + Id = scenarioId, + BuildWorld = BuildSameMoveAscendStaircase, + Start = new Location(340.5, 80, 340.5), + Goal = new GoalBlock(345, 85, 340), + StartYaw = 270f, + MaxExecutionTicks = 420 + }, + "same-move-descend-staircase" => new PathingExecutionScenario + { + Id = scenarioId, + BuildWorld = BuildSameMoveDescendStaircase, + Start = new Location(362.5, 85, 360.5), + Goal = new GoalBlock(367, 80, 360), + StartYaw = 270f, + MaxExecutionTicks = 420 + }, + "rejected-3x1-invalid-goal" => new PathingExecutionScenario + { + Id = scenarioId, + BuildWorld = BuildRejectedThreeByOneInvalidGoal, + Start = new Location(141.5, 80, 138.5), + Goal = new GoalBlock(144, 81, 138), + StartYaw = 270f, + MaxExecutionTicks = 80 + }, _ => throw new ArgumentOutOfRangeException(nameof(scenarioId), scenarioId, null) }; @@ -28,4 +55,37 @@ internal static class PathingExecutionScenarioCatalog FlatWorldTestBuilder.SetSolid(world, 177, 82, 162); return world; } + + private static World BuildSameMoveAscendStaircase() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 338, max: 347); + FlatWorldTestBuilder.ClearBox(world, 340, 80, 338, 347, 86, 342); + FlatWorldTestBuilder.FillSolid(world, 341, 80, 339, 341, 80, 341); + FlatWorldTestBuilder.FillSolid(world, 342, 81, 339, 342, 81, 341); + FlatWorldTestBuilder.FillSolid(world, 343, 82, 339, 343, 82, 341); + FlatWorldTestBuilder.FillSolid(world, 344, 83, 339, 344, 83, 341); + FlatWorldTestBuilder.FillSolid(world, 345, 84, 339, 345, 84, 341); + return world; + } + + private static World BuildSameMoveDescendStaircase() + { + 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); + return world; + } + + private static World BuildRejectedThreeByOneInvalidGoal() + { + World world = FlatWorldTestBuilder.CreateStoneFloor(min: 135, max: 148); + FlatWorldTestBuilder.ClearBox(world, 140, 80, 135, 148, 85, 140); + FlatWorldTestBuilder.SetSolid(world, 143, 80, 138); + return world; + } } diff --git a/MinecraftClient.Tests/Pathing/Execution/Support/PathingContractAssert.cs b/MinecraftClient.Tests/Pathing/Execution/Support/PathingContractAssert.cs new file mode 100644 index 00000000..1a97476c --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/Support/PathingContractAssert.cs @@ -0,0 +1,69 @@ +using System.Text; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Tests.Pathing.Execution.Contracts; +using Xunit; +using Xunit.Sdk; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal static class PathingContractAssert +{ + internal static void PlannerMatches(PathingPlannerContract contract, IReadOnlyList segments, PathResult result) + { + if (result.Status != contract.ExpectedStatus) + throw new XunitException($"planner status mismatch: expected {contract.ExpectedStatus}, got {result.Status}"); + + if (segments.Count != contract.Segments.Count) + throw new XunitException($"segment count mismatch: expected {contract.Segments.Count}, got {segments.Count}"); + + for (int i = 0; i < segments.Count; i++) + { + PathSegment actual = segments[i]; + PathingPlannerSegmentContract expected = contract.Segments[i]; + + Assert.Equal(expected.MoveType, actual.MoveType); + Assert.Equal(expected.StartBlock, ToBlock(actual.Start)); + Assert.Equal(expected.EndBlock, ToBlock(actual.End)); + } + } + + internal static void TimingMatches(PathingTimingBudget budget, PathingScenarioResult result) + { + if (!result.Completed) + throw new XunitException("navigation did not complete"); + + if (result.ReplanCount != 0) + throw new XunitException($"expected 0 replans, saw {result.ReplanCount}\n{Format(result, budget)}"); + + if (result.TotalTicks > budget.MaxTotalTicks) + throw new XunitException($"route exceeded budget: actual={result.TotalTicks} max={budget.MaxTotalTicks}\n{Format(result, budget)}"); + + if (result.SegmentRuns.Count != budget.Segments.Count) + throw new XunitException($"segment timing count mismatch: actual={result.SegmentRuns.Count} expected={budget.Segments.Count}"); + + for (int i = 0; i < budget.Segments.Count; i++) + { + if (result.SegmentRuns[i].ElapsedTicks > budget.Segments[i].MaxTicks) + throw new XunitException($"segment {i} exceeded budget\n{Format(result, budget)}"); + } + } + + private static string Format(PathingScenarioResult result, PathingTimingBudget budget) + { + var sb = new StringBuilder(); + sb.AppendLine($"route actual={result.TotalTicks} expected={budget.ExpectedTotalTicks} max={budget.MaxTotalTicks}"); + for (int i = 0; i < result.SegmentRuns.Count; i++) + { + PathSegmentRun actual = result.SegmentRuns[i]; + PathingSegmentTimingBudget expected = budget.Segments[i]; + sb.AppendLine($"seg[{i}] move={actual.MoveType} actual={actual.ElapsedTicks} expected={expected.ExpectedTicks} max={expected.MaxTicks}"); + } + + return sb.ToString(); + } + + private static PathingBlock ToBlock(Location location) => + new((int)Math.Floor(location.X), (int)Math.Floor(location.Y), (int)Math.Floor(location.Z)); +} diff --git a/MinecraftClient.Tests/Pathing/Execution/Support/PathingContractBootstrapWriter.cs b/MinecraftClient.Tests/Pathing/Execution/Support/PathingContractBootstrapWriter.cs new file mode 100644 index 00000000..82deb0da --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/Support/PathingContractBootstrapWriter.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal static class PathingContractBootstrapWriter +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true + }; + + static PathingContractBootstrapWriter() + { + JsonOptions.Converters.Add(new JsonStringEnumConverter()); + } + + internal static string WritePlannerFragment(string scenarioId, PathResult planResult) + { + IReadOnlyList segments = PathSegmentBuilder.FromPath(planResult.Path); + + return JsonSerializer.Serialize(new + { + scenarioId, + expectedStatus = planResult.Status, + segments = segments.Select(segment => new + { + moveType = segment.MoveType, + startBlock = ToBlockObject(segment.Start), + endBlock = ToBlockObject(segment.End) + }) + }, JsonOptions); + } + + internal static string WriteTimingFragment(string scenarioId, PathingScenarioResult result) + { + return JsonSerializer.Serialize(new + { + scenarioId, + expectedTotalTicks = result.TotalTicks, + maxTotalTicks = SeedMaxTicks(result.TotalTicks), + segments = result.SegmentRuns.Select(run => new + { + moveType = run.MoveType, + expectedTicks = run.ElapsedTicks, + maxTicks = SeedMaxTicks(run.ElapsedTicks) + }) + }, JsonOptions); + } + + private static object ToBlockObject(MinecraftClient.Mapping.Location location) => new + { + x = (int)Math.Floor(location.X), + y = (int)Math.Floor(location.Y), + z = (int)Math.Floor(location.Z) + }; + + private static int SeedMaxTicks(int expectedTicks) => + expectedTicks + Math.Max(2, (int)Math.Ceiling(expectedTicks * 0.20)); +} diff --git a/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json b/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json index baaf9d41..1106513c 100644 --- a/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json +++ b/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json @@ -34,5 +34,62 @@ "endBlock": { "x": 177, "y": 83, "z": 162 } } ] + }, + { + "scenarioId": "same-move-ascend-staircase", + "expectedStatus": "Success", + "segments": [ + { + "moveType": "Ascend", + "startBlock": { "x": 340, "y": 80, "z": 340 }, + "endBlock": { "x": 341, "y": 81, "z": 340 } + }, + { + "moveType": "Ascend", + "startBlock": { "x": 341, "y": 81, "z": 340 }, + "endBlock": { "x": 342, "y": 82, "z": 340 } + }, + { + "moveType": "Ascend", + "startBlock": { "x": 342, "y": 82, "z": 340 }, + "endBlock": { "x": 343, "y": 83, "z": 340 } + }, + { + "moveType": "Ascend", + "startBlock": { "x": 343, "y": 83, "z": 340 }, + "endBlock": { "x": 344, "y": 84, "z": 340 } + }, + { + "moveType": "Ascend", + "startBlock": { "x": 344, "y": 84, "z": 340 }, + "endBlock": { "x": 345, "y": 85, "z": 340 } + } + ] + }, + { + "scenarioId": "same-move-descend-staircase", + "expectedStatus": "Success", + "segments": [ + { + "moveType": "Descend", + "startBlock": { "x": 362, "y": 85, "z": 360 }, + "endBlock": { "x": 364, "y": 83, "z": 360 } + }, + { + "moveType": "Descend", + "startBlock": { "x": 364, "y": 83, "z": 360 }, + "endBlock": { "x": 366, "y": 81, "z": 360 } + }, + { + "moveType": "Descend", + "startBlock": { "x": 366, "y": 81, "z": 360 }, + "endBlock": { "x": 367, "y": 80, "z": 360 } + } + ] + }, + { + "scenarioId": "rejected-3x1-invalid-goal", + "expectedStatus": "Failed", + "segments": [] } ] diff --git a/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json b/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json index 2d935b5a..03d9e01e 100644 --- a/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json +++ b/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json @@ -11,5 +11,33 @@ { "moveType": "Ascend", "expectedTicks": 0, "maxTicks": 0 }, { "moveType": "Ascend", "expectedTicks": 0, "maxTicks": 0 } ] + }, + { + "scenarioId": "same-move-ascend-staircase", + "expectedTotalTicks": 56, + "maxTotalTicks": 68, + "segments": [ + { "moveType": "Ascend", "expectedTicks": 11, "maxTicks": 14 }, + { "moveType": "Ascend", "expectedTicks": 11, "maxTicks": 14 }, + { "moveType": "Ascend", "expectedTicks": 11, "maxTicks": 14 }, + { "moveType": "Ascend", "expectedTicks": 11, "maxTicks": 14 }, + { "moveType": "Ascend", "expectedTicks": 12, "maxTicks": 15 } + ] + }, + { + "scenarioId": "same-move-descend-staircase", + "expectedTotalTicks": 61, + "maxTotalTicks": 74, + "segments": [ + { "moveType": "Descend", "expectedTicks": 24, "maxTicks": 29 }, + { "moveType": "Descend", "expectedTicks": 25, "maxTicks": 30 }, + { "moveType": "Descend", "expectedTicks": 12, "maxTicks": 15 } + ] + }, + { + "scenarioId": "rejected-3x1-invalid-goal", + "expectedTotalTicks": 0, + "maxTotalTicks": 0, + "segments": [] } ]