Add path execution telemetry and scenario runner

This commit is contained in:
BruceChen 2026-04-13 16:36:11 +00:00
parent 9fe376a3bb
commit b9bff02107
8 changed files with 215 additions and 4 deletions

View file

@ -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));
}
}

View file

@ -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<World> 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; }
}

View file

@ -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;
}
}

View file

@ -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<PathSegmentRun> SegmentRuns,
IReadOnlyList<string> DebugLogs,
IReadOnlyList<string> 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<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();
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);
}
}

View file

@ -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<PathSegmentRun> SegmentRuns { get; } = [];
internal int ReplanCount { get; private set; }
internal int TotalTicks { get; private set; }
public void OnNavigationStarted(IReadOnlyList<PathSegment> 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<PathSegment> segments) { }
public void OnReplanFailed(int replanCount, Location position) { }
}