diff --git a/MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs new file mode 100644 index 00000000..c9daf70f --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/PathTimingContractTests.cs @@ -0,0 +1,19 @@ +using Xunit; + +namespace MinecraftClient.Tests.Pathing.Execution; + +public sealed class PathTimingContractTests +{ + [Fact] + public void Run_ManagerAcceptedAscendChain_CapturesPerSegmentTicks_AndZeroReplan() + { + PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get("manager-accepted-ascend-chain"); + + PathingScenarioResult result = PathingScenarioRunner.RunAccepted(scenario); + + Assert.Equal(0, result.ReplanCount); + Assert.True(result.Completed); + Assert.Equal(6, result.SegmentRuns.Count); + Assert.All(result.SegmentRuns, run => Assert.True(run.ElapsedTicks > 0)); + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenario.cs b/MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenario.cs new file mode 100644 index 00000000..a561c02c --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenario.cs @@ -0,0 +1,14 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Goals; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal sealed record PathingExecutionScenario +{ + public required string Id { get; init; } + public required Func BuildWorld { get; init; } + public required Location Start { get; init; } + public required GoalBlock Goal { get; init; } + public required float StartYaw { get; init; } + public required int MaxExecutionTicks { get; init; } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs b/MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs new file mode 100644 index 00000000..ec3a82cc --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/Scenarios/PathingExecutionScenarioCatalog.cs @@ -0,0 +1,31 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Goals; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal static class PathingExecutionScenarioCatalog +{ + internal static PathingExecutionScenario Get(string scenarioId) => scenarioId switch + { + "manager-accepted-ascend-chain" => new PathingExecutionScenario + { + Id = scenarioId, + BuildWorld = BuildManagerAcceptedAscendChain, + Start = new Location(171.5, 80, 160.5), + Goal = new GoalBlock(177, 83, 162), + StartYaw = 315f, + MaxExecutionTicks = 420 + }, + _ => throw new ArgumentOutOfRangeException(nameof(scenarioId), scenarioId, null) + }; + + private static World BuildManagerAcceptedAscendChain() + { + 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); + return world; + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/Support/PathingScenarioRunner.cs b/MinecraftClient.Tests/Pathing/Execution/Support/PathingScenarioRunner.cs new file mode 100644 index 00000000..24c830a7 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/Support/PathingScenarioRunner.cs @@ -0,0 +1,76 @@ +using System.Threading; +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Physics; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal sealed record PathingScenarioResult( + bool Completed, + int ReplanCount, + int TotalTicks, + IReadOnlyList SegmentRuns, + IReadOnlyList DebugLogs, + IReadOnlyList InfoLogs, + Location FinalPosition, + PathResult PlanResult); + +internal static class PathingScenarioRunner +{ + internal static PathResult PlanOnly(PathingExecutionScenario scenario) + { + World world = scenario.BuildWorld(); + var ctx = new CalculationContext(world, allowParkour: true, allowParkourAscend: true); + var finder = new AStarPathFinder(); + + return finder.Calculate( + ctx, + (int)Math.Floor(scenario.Start.X), + (int)Math.Floor(scenario.Start.Y), + (int)Math.Floor(scenario.Start.Z), + scenario.Goal, + CancellationToken.None, + timeoutMs: 3000); + } + + internal static PathingScenarioResult RunAccepted(PathingExecutionScenario scenario) + { + World world = scenario.BuildWorld(); + var debugLogs = new List(); + var infoLogs = new List(); + 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(); + + PathResult planResult = 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); + if (!manager.IsNavigating) + break; + + physics.ApplyInput(input); + physics.Tick(world); + } + + Location finalPosition = new(physics.Position.X, physics.Position.Y, physics.Position.Z); + bool completed = !manager.IsNavigating && manager.Goal is null && observer.TotalTicks > 0; + + return new PathingScenarioResult( + completed, + observer.ReplanCount, + observer.TotalTicks, + observer.SegmentRuns.AsReadOnly(), + debugLogs.AsReadOnly(), + infoLogs.AsReadOnly(), + finalPosition, + planResult); + } +} diff --git a/MinecraftClient.Tests/Pathing/Execution/Support/RecordingPathExecutionObserver.cs b/MinecraftClient.Tests/Pathing/Execution/Support/RecordingPathExecutionObserver.cs new file mode 100644 index 00000000..f862c351 --- /dev/null +++ b/MinecraftClient.Tests/Pathing/Execution/Support/RecordingPathExecutionObserver.cs @@ -0,0 +1,33 @@ +using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution; +using MinecraftClient.Pathing.Execution.Telemetry; + +namespace MinecraftClient.Tests.Pathing.Execution; + +internal sealed record PathSegmentRun(int SegmentIndex, MoveType MoveType, int ElapsedTicks, Location Position); + +internal sealed class RecordingPathExecutionObserver : IPathExecutionObserver +{ + internal List SegmentRuns { get; } = []; + internal int ReplanCount { get; private set; } + internal int TotalTicks { get; private set; } + + public void OnNavigationStarted(IReadOnlyList segments) { } + + public void OnSegmentStarted(int segmentIndex, int totalSegments, PathSegment segment) { } + + public void OnSegmentCompleted(int segmentIndex, int totalSegments, PathSegment segment, int elapsedTicks, Location position) + => SegmentRuns.Add(new PathSegmentRun(segmentIndex, segment.MoveType, elapsedTicks, position)); + + public void OnSegmentFailed(int segmentIndex, int totalSegments, PathSegment segment, int elapsedTicks, Location position) + => SegmentRuns.Add(new PathSegmentRun(segmentIndex, segment.MoveType, elapsedTicks, position)); + + public void OnNavigationCompleted(int totalTicks) => TotalTicks = totalTicks; + + public void OnReplanStarted(int replanCount, Location position) => ReplanCount = replanCount; + + public void OnReplanSucceeded(int replanCount, IReadOnlyList segments) { } + + public void OnReplanFailed(int replanCount, Location position) { } +} diff --git a/MinecraftClient/Pathing/Execution/PathExecutor.cs b/MinecraftClient/Pathing/Execution/PathExecutor.cs index 00d788e1..247fdae1 100644 --- a/MinecraftClient/Pathing/Execution/PathExecutor.cs +++ b/MinecraftClient/Pathing/Execution/PathExecutor.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using MinecraftClient.Mapping; +using MinecraftClient.Pathing.Execution.Telemetry; using MinecraftClient.Physics; namespace MinecraftClient.Pathing.Execution @@ -22,18 +23,24 @@ namespace MinecraftClient.Pathing.Execution private int _currentIndex; private IActionTemplate? _currentTemplate; private readonly Action? _debugLog; + private readonly IPathExecutionObserver? _observer; + private int _segmentTicks; + private int _totalTicks; public bool IsComplete => _currentIndex >= _segments.Count && _currentTemplate is null; public int CurrentIndex => _currentIndex; public int TotalSegments => _segments.Count; + public int TotalTicks => _totalTicks; public PathSegment? CurrentSegment => _currentIndex < _segments.Count ? _segments[_currentIndex] : null; - public PathExecutor(List segments, Action? debugLog = null) + public PathExecutor(List segments, Action? debugLog = null, IPathExecutionObserver? observer = null) { _segments = segments; _currentIndex = 0; _debugLog = debugLog; + _observer = observer; + _observer?.OnNavigationStarted(segments); AdvanceToNextSegment(); } @@ -45,15 +52,19 @@ namespace MinecraftClient.Pathing.Execution return PathExecutorState.Complete; } + _segmentTicks++; + _totalTicks++; var state = _currentTemplate.Tick(pos, physics, input, world); switch (state) { 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; @@ -65,6 +76,7 @@ namespace MinecraftClient.Pathing.Execution 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})"); @@ -82,6 +94,7 @@ namespace MinecraftClient.Pathing.Execution var seg = _segments[_currentIndex]; PathSegment? next = _currentIndex + 1 < _segments.Count ? _segments[_currentIndex + 1] : null; _currentTemplate = ActionTemplateFactory.Create(seg, next); + _observer?.OnSegmentStarted(_currentIndex, _segments.Count, seg); _debugLog?.Invoke($"[PathExec] Starting segment {_currentIndex}/{_segments.Count}: {seg}"); } else diff --git a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs index 41c917b7..dd54de5f 100644 --- a/MinecraftClient/Pathing/Execution/PathSegmentManager.cs +++ b/MinecraftClient/Pathing/Execution/PathSegmentManager.cs @@ -2,6 +2,7 @@ using System; using System.Threading; using MinecraftClient.Mapping; using MinecraftClient.Pathing.Core; +using MinecraftClient.Pathing.Execution.Telemetry; using MinecraftClient.Pathing.Goals; using MinecraftClient.Physics; @@ -20,15 +21,17 @@ namespace MinecraftClient.Pathing.Execution private readonly Action? _debugLog; private readonly Action? _infoLog; + private readonly IPathExecutionObserver? _observer; public bool IsNavigating => _executor is not null && !_executor.IsComplete; public int ReplanCount => _replanCount; public IGoal? Goal => _goal; - public PathSegmentManager(Action? debugLog = null, Action? infoLog = null) + public PathSegmentManager(Action? debugLog = null, Action? infoLog = null, IPathExecutionObserver? observer = null) { _debugLog = debugLog; _infoLog = infoLog; + _observer = observer; } public void StartNavigation(IGoal goal, PathResult result) @@ -36,7 +39,7 @@ namespace MinecraftClient.Pathing.Execution _goal = goal; _replanCount = 0; var segments = PathSegmentBuilder.FromPath(result.Path); - _executor = new PathExecutor(segments, _debugLog); + _executor = new PathExecutor(segments, _debugLog, _observer); _infoLog?.Invoke($"[PathMgr] Navigation started: {segments.Count} segments"); } @@ -50,6 +53,7 @@ namespace MinecraftClient.Pathing.Execution switch (state) { case PathExecutorState.Complete: + _observer?.OnNavigationCompleted(_executor.TotalTicks); _infoLog?.Invoke("[PathMgr] Navigation complete!"); _executor = null; _goal = null; @@ -75,8 +79,10 @@ namespace MinecraftClient.Pathing.Execution private void Replan(Location pos, World world) { _replanCount++; + _observer?.OnReplanStarted(_replanCount, pos); if (_replanCount > MaxReplans) { + _observer?.OnReplanFailed(_replanCount, pos); _infoLog?.Invoke($"[PathMgr] Giving up after {MaxReplans} replans."); _executor = null; _goal = null; @@ -117,6 +123,7 @@ namespace MinecraftClient.Pathing.Execution if (result.Status == PathStatus.Failed || result.Path.Count < 2) { + _observer?.OnReplanFailed(_replanCount, pos); _infoLog?.Invoke("[PathMgr] Replan failed -- no path found."); _executor = null; _goal = null; @@ -124,7 +131,8 @@ namespace MinecraftClient.Pathing.Execution } var segments = PathSegmentBuilder.FromPath(result.Path); - _executor = new PathExecutor(segments, _debugLog); + _observer?.OnReplanSucceeded(_replanCount, segments); + _executor = new PathExecutor(segments, _debugLog, _observer); _infoLog?.Invoke($"[PathMgr] Replanned: {segments.Count} segments (replan #{_replanCount})"); } } diff --git a/MinecraftClient/Pathing/Execution/Telemetry/IPathExecutionObserver.cs b/MinecraftClient/Pathing/Execution/Telemetry/IPathExecutionObserver.cs new file mode 100644 index 00000000..f65fd25b --- /dev/null +++ b/MinecraftClient/Pathing/Execution/Telemetry/IPathExecutionObserver.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using MinecraftClient.Mapping; + +namespace MinecraftClient.Pathing.Execution.Telemetry +{ + public interface IPathExecutionObserver + { + void OnNavigationStarted(IReadOnlyList segments); + void OnSegmentStarted(int segmentIndex, int totalSegments, PathSegment segment); + void OnSegmentCompleted(int segmentIndex, int totalSegments, PathSegment segment, int elapsedTicks, Location position); + void OnSegmentFailed(int segmentIndex, int totalSegments, PathSegment segment, int elapsedTicks, Location position); + void OnNavigationCompleted(int totalTicks); + void OnReplanStarted(int replanCount, Location position); + void OnReplanSucceeded(int replanCount, IReadOnlyList segments); + void OnReplanFailed(int replanCount, Location position); + } +}